@ouraihub/aether-core 0.1.0 → 0.1.2
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/package.json +2 -2
- package/src/engine/index.ts +95 -129
- package/src/engine/machine.ts +322 -0
- package/src/engine/model.ts +28 -16
- package/src/engine/prepare.ts +120 -34
- package/src/engine/tools.ts +59 -142
- package/src/error.ts +7 -2
- package/src/guard.ts +3 -1
- package/src/history.ts +9 -2
- package/src/images/index.ts +212 -39
- package/src/images/validation.ts +276 -0
- package/src/mcp/index.ts +83 -245
- package/src/mcp/protocol.ts +207 -0
- package/src/mcp/types.ts +49 -0
- package/src/pricing.ts +10 -2
- package/src/protocol/decoder.ts +39 -14
- package/src/protocol/request.ts +52 -17
- package/src/providers/connection.ts +16 -5
- package/src/providers/infer.ts +11 -6
- package/src/providers/probe.ts +20 -6
- package/src/risk.ts +13 -3
- package/src/schema.ts +34 -8
- package/src/search/execution.ts +21 -6
- package/src/search/index.ts +11 -2
- package/src/search/policy.ts +43 -10
- package/src/search/vendor.ts +42 -9
- package/src/skills/content.ts +13 -3
- package/src/skills/index.ts +6 -1
- package/src/skills/package.ts +29 -7
- package/src/skills/service.ts +54 -13
- package/src/skills/validation.ts +38 -9
package/src/images/index.ts
CHANGED
|
@@ -1,19 +1,38 @@
|
|
|
1
|
-
import type { ImagePart, KitErrorCode, Details } from '@ouraihub/aether-contracts'
|
|
1
|
+
import type { ImagePart, KitErrorCode, Details, TokenUsage } from '@ouraihub/aether-contracts'
|
|
2
2
|
import { badRequest, KitFailure } from '../error.js'
|
|
3
3
|
import type { OutboundHttp } from '../ports.js'
|
|
4
4
|
import type { Connection } from '../types.js'
|
|
5
|
+
import { emptyUsage } from '../pricing.js'
|
|
6
|
+
import { validateImageBytes } from './validation.js'
|
|
7
|
+
export { validateImageBytes } from './validation.js'
|
|
5
8
|
|
|
6
9
|
export const MAX_IMAGE_BYTES = 4 * 1024 * 1024
|
|
7
10
|
export const MAX_EDIT_IMAGES = 4
|
|
8
11
|
export const IMAGE_MIMES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const
|
|
9
12
|
export const IMAGE_SIZES = ['1024x1024', '1024x1536', '1536x1024'] as const
|
|
10
13
|
export const IMAGE_TIMEOUT_MS = 120_000
|
|
11
|
-
const
|
|
14
|
+
export const MAX_GENERATED_IMAGE_BYTES = 10 * 1024 * 1024
|
|
15
|
+
export const MAX_IMAGE_INPUT_BYTES = 20 * 1024 * 1024
|
|
16
|
+
export type ImageProfile = 'generic' | 'gpt-image' | 'siliconflow'
|
|
17
|
+
export type ImageQuality = 'low' | 'medium' | 'high'
|
|
18
|
+
export interface ImageLimits {
|
|
19
|
+
readonly maxImageBytes?: number
|
|
20
|
+
readonly timeoutMs?: number
|
|
21
|
+
readonly profile?: ImageProfile
|
|
22
|
+
}
|
|
23
|
+
export interface ImageGeneration {
|
|
24
|
+
readonly image: ImagePart
|
|
25
|
+
readonly revisedPrompt: string | null
|
|
26
|
+
readonly usage: TokenUsage
|
|
27
|
+
readonly totalTokens: number | null
|
|
28
|
+
}
|
|
12
29
|
export type ImageSize = (typeof IMAGE_SIZES)[number]
|
|
13
30
|
export interface ImageRequest {
|
|
14
31
|
readonly prompt: string
|
|
15
32
|
readonly size: ImageSize
|
|
16
33
|
readonly images: readonly ImagePart[]
|
|
34
|
+
readonly quality?: ImageQuality
|
|
35
|
+
readonly mask?: ImagePart
|
|
17
36
|
}
|
|
18
37
|
export type ImageConnection = Pick<
|
|
19
38
|
Connection,
|
|
@@ -55,7 +74,7 @@ export function checkImages(images: unknown): asserts images is ImagePart[] {
|
|
|
55
74
|
for (const image of images) parseImage(image)
|
|
56
75
|
}
|
|
57
76
|
|
|
58
|
-
export function decodeImage(image: ImagePart): Uint8Array<ArrayBuffer> {
|
|
77
|
+
export function decodeImage(image: ImagePart, maxBytes = MAX_IMAGE_BYTES): Uint8Array<ArrayBuffer> {
|
|
59
78
|
if (
|
|
60
79
|
!record(image) ||
|
|
61
80
|
Object.keys(image).some((key) => key !== 'mime' && key !== 'data') ||
|
|
@@ -65,8 +84,9 @@ export function decodeImage(image: ImagePart): Uint8Array<ArrayBuffer> {
|
|
|
65
84
|
throw badRequest('images', 'encoding')
|
|
66
85
|
if (!IMAGE_MIMES.some((mime) => mime === image.mime)) throw badRequest('images', 'mime')
|
|
67
86
|
const data = image.data
|
|
68
|
-
if (data.length > Math.ceil(
|
|
69
|
-
if (data.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/u.test(data))
|
|
87
|
+
if (data.length > Math.ceil(maxBytes / 3) * 4) throw badRequest('images', 'size', maxBytes)
|
|
88
|
+
if (data.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/u.test(data))
|
|
89
|
+
throw badRequest('images', 'encoding')
|
|
70
90
|
let binary: string
|
|
71
91
|
try {
|
|
72
92
|
binary = atob(data)
|
|
@@ -75,7 +95,7 @@ export function decodeImage(image: ImagePart): Uint8Array<ArrayBuffer> {
|
|
|
75
95
|
}
|
|
76
96
|
// atob accepts noncanonical padding bits; standard base64 does not.
|
|
77
97
|
if (btoa(binary) !== data) throw badRequest('images', 'encoding')
|
|
78
|
-
if (binary.length >
|
|
98
|
+
if (binary.length > maxBytes) throw badRequest('images', 'size', maxBytes)
|
|
79
99
|
return Uint8Array.from(binary, (character) => character.charCodeAt(0))
|
|
80
100
|
}
|
|
81
101
|
|
|
@@ -86,15 +106,6 @@ export function encodeImage(bytes: Uint8Array): string {
|
|
|
86
106
|
return btoa(binary)
|
|
87
107
|
}
|
|
88
108
|
|
|
89
|
-
function imageMime(bytes: Uint8Array): string {
|
|
90
|
-
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return 'image/png'
|
|
91
|
-
if (bytes[0] === 0xff && bytes[1] === 0xd8) return 'image/jpeg'
|
|
92
|
-
const signature = (start: number, end: number): string => String.fromCharCode(...bytes.subarray(start, end))
|
|
93
|
-
if (signature(0, 4) === 'RIFF' && signature(8, 12) === 'WEBP') return 'image/webp'
|
|
94
|
-
if (signature(0, 6) === 'GIF87a' || signature(0, 6) === 'GIF89a') return 'image/gif'
|
|
95
|
-
throw badRequest('images', 'mime')
|
|
96
|
-
}
|
|
97
|
-
|
|
98
109
|
function interrupted(signal: AbortSignal): KitFailure {
|
|
99
110
|
return signal.reason instanceof DOMException && signal.reason.name === 'TimeoutError'
|
|
100
111
|
? new KitFailure('timeout', 'Image request deadline exceeded')
|
|
@@ -113,7 +124,11 @@ function abortable<T>(pending: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
|
113
124
|
return promise
|
|
114
125
|
}
|
|
115
126
|
|
|
116
|
-
async function bounded(
|
|
127
|
+
async function bounded(
|
|
128
|
+
response: Response,
|
|
129
|
+
limit: number,
|
|
130
|
+
signal: AbortSignal,
|
|
131
|
+
): Promise<Uint8Array> {
|
|
117
132
|
const length = response.headers.get('content-length')
|
|
118
133
|
if (length !== null && (!/^\d+$/u.test(length) || Number(length) > limit)) {
|
|
119
134
|
void response.body?.cancel().catch(() => {})
|
|
@@ -155,7 +170,13 @@ function endpoint(base: string, edit: boolean): string {
|
|
|
155
170
|
} catch {
|
|
156
171
|
throw badRequest('url', 'invalid')
|
|
157
172
|
}
|
|
158
|
-
if (
|
|
173
|
+
if (
|
|
174
|
+
!['https:', 'http:'].includes(url.protocol) ||
|
|
175
|
+
url.username ||
|
|
176
|
+
url.password ||
|
|
177
|
+
url.search ||
|
|
178
|
+
url.hash
|
|
179
|
+
)
|
|
159
180
|
throw badRequest('url', 'invalid')
|
|
160
181
|
return url.href
|
|
161
182
|
}
|
|
@@ -172,6 +193,28 @@ function downloadTarget(value: string): string {
|
|
|
172
193
|
return url.href
|
|
173
194
|
}
|
|
174
195
|
|
|
196
|
+
/** Only known profiles enable provider-specific options; callers may explicitly select one. */
|
|
197
|
+
export function imageCapabilities(connection: ImageConnection, limits: ImageLimits = {}) {
|
|
198
|
+
const host = new URL(endpoint(connection.imageBaseUrl ?? connection.baseUrl, false)).hostname
|
|
199
|
+
const profile =
|
|
200
|
+
limits.profile ??
|
|
201
|
+
(['api.siliconflow.cn', 'api.siliconflow.com'].includes(host)
|
|
202
|
+
? 'siliconflow'
|
|
203
|
+
: ['gpt-image-1', 'gpt-image-1-mini', 'gpt-image-1.5', 'gpt-image-2'].includes(
|
|
204
|
+
connection.modelId,
|
|
205
|
+
)
|
|
206
|
+
? 'gpt-image'
|
|
207
|
+
: 'generic')
|
|
208
|
+
if (!['generic', 'gpt-image', 'siliconflow'].includes(profile))
|
|
209
|
+
throw badRequest('profile', 'invalid')
|
|
210
|
+
return {
|
|
211
|
+
profile,
|
|
212
|
+
edit: connection.canVision && profile !== 'siliconflow',
|
|
213
|
+
quality: profile === 'gpt-image',
|
|
214
|
+
mask: profile === 'gpt-image',
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
175
218
|
/**
|
|
176
219
|
* One independent generation/edit. OutboundHttp MUST enforce host address policy
|
|
177
220
|
* for BOTH endpoint and returned download URLs, including DNS, and reject redirects.
|
|
@@ -182,26 +225,73 @@ export async function generateImage(
|
|
|
182
225
|
connection: ImageConnection,
|
|
183
226
|
input: ImageRequest,
|
|
184
227
|
signal: AbortSignal,
|
|
185
|
-
|
|
228
|
+
limits: ImageLimits = {},
|
|
229
|
+
): Promise<ImageGeneration> {
|
|
186
230
|
let dispatch: ImageDispatch = 'not_dispatched'
|
|
187
231
|
const controller = new AbortController()
|
|
188
232
|
const cancel = (): void => controller.abort(signal.reason)
|
|
189
233
|
signal.addEventListener('abort', cancel, { once: true })
|
|
190
234
|
if (signal.aborted) cancel()
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
235
|
+
const timeoutMs = limits.timeoutMs ?? IMAGE_TIMEOUT_MS
|
|
236
|
+
const maxImageBytes = limits.maxImageBytes ?? MAX_IMAGE_BYTES
|
|
237
|
+
const validLimits =
|
|
238
|
+
Number.isSafeInteger(timeoutMs) &&
|
|
239
|
+
timeoutMs > 0 &&
|
|
240
|
+
timeoutMs <= 180_000 &&
|
|
241
|
+
Number.isSafeInteger(maxImageBytes) &&
|
|
242
|
+
maxImageBytes > 0 &&
|
|
243
|
+
maxImageBytes <= MAX_GENERATED_IMAGE_BYTES
|
|
244
|
+
const timer = validLimits
|
|
245
|
+
? setTimeout(
|
|
246
|
+
() => controller.abort(new DOMException('Image request deadline exceeded', 'TimeoutError')),
|
|
247
|
+
timeoutMs,
|
|
248
|
+
)
|
|
249
|
+
: undefined
|
|
195
250
|
const active = controller.signal
|
|
196
251
|
try {
|
|
197
252
|
if (active.aborted) throw interrupted(active)
|
|
253
|
+
if (!validLimits) throw badRequest('limits', 'invalid')
|
|
198
254
|
const prompt = input.prompt.trim()
|
|
199
255
|
if (!prompt || [...prompt].length > 4000) throw badRequest('prompt', 'invalid')
|
|
200
256
|
if (!IMAGE_SIZES.some((size) => size === input.size)) throw badRequest('size', 'invalid')
|
|
201
257
|
if (input.images.length > MAX_EDIT_IMAGES) throw badRequest('images', 'count', MAX_EDIT_IMAGES)
|
|
202
258
|
const edit = input.images.length > 0
|
|
203
|
-
|
|
204
|
-
|
|
259
|
+
const capabilities = imageCapabilities(connection, limits)
|
|
260
|
+
if (edit && !capabilities.edit) throw new KitFailure('unsupported', 'Image editing unsupported')
|
|
261
|
+
if (input.quality !== undefined) {
|
|
262
|
+
if (!['low', 'medium', 'high'].includes(input.quality)) throw badRequest('quality', 'invalid')
|
|
263
|
+
if (!capabilities.quality) throw new KitFailure('unsupported', 'Image quality unsupported')
|
|
264
|
+
}
|
|
265
|
+
if (input.mask !== undefined && (!edit || !capabilities.mask))
|
|
266
|
+
throw new KitFailure('unsupported', 'Image mask unsupported')
|
|
267
|
+
let inputBytes = 0
|
|
268
|
+
const decoded = input.images.map((image) => {
|
|
269
|
+
const bytes = decodeImage(image, maxImageBytes)
|
|
270
|
+
validateImageBytes(bytes, image.mime, maxImageBytes)
|
|
271
|
+
inputBytes += bytes.length
|
|
272
|
+
return bytes
|
|
273
|
+
})
|
|
274
|
+
const maskBytes = input.mask === undefined ? undefined : decodeImage(input.mask, maxImageBytes)
|
|
275
|
+
if (maskBytes && input.mask) {
|
|
276
|
+
const mask = validateImageBytes(maskBytes, 'image/png', maxImageBytes)
|
|
277
|
+
const firstBytes = decoded[0]
|
|
278
|
+
if (firstBytes === undefined) throw badRequest('mask', 'requires_image')
|
|
279
|
+
const first = validateImageBytes(firstBytes, undefined, maxImageBytes)
|
|
280
|
+
if (
|
|
281
|
+
input.mask.mime !== 'image/png' ||
|
|
282
|
+
!mask.alpha ||
|
|
283
|
+
mask.width !== first.width ||
|
|
284
|
+
mask.height !== first.height
|
|
285
|
+
)
|
|
286
|
+
throw badRequest('mask', 'invalid')
|
|
287
|
+
inputBytes += maskBytes.length
|
|
288
|
+
}
|
|
289
|
+
if (inputBytes > MAX_IMAGE_INPUT_BYTES)
|
|
290
|
+
throw badRequest('images', 'size', MAX_IMAGE_INPUT_BYTES)
|
|
291
|
+
const target = endpoint(
|
|
292
|
+
(edit ? connection.imageEditBaseUrl : connection.imageBaseUrl) ?? connection.baseUrl,
|
|
293
|
+
edit,
|
|
294
|
+
)
|
|
205
295
|
let payload: BodyInit
|
|
206
296
|
if (edit) {
|
|
207
297
|
const form = new FormData()
|
|
@@ -209,9 +299,16 @@ export async function generateImage(
|
|
|
209
299
|
form.set('prompt', prompt)
|
|
210
300
|
form.set('size', input.size)
|
|
211
301
|
form.set('n', '1')
|
|
302
|
+
if (capabilities.profile === 'gpt-image') {
|
|
303
|
+
form.set('output_format', 'png')
|
|
304
|
+
form.set('background', 'opaque')
|
|
305
|
+
}
|
|
306
|
+
if (input.quality !== undefined) form.set('quality', input.quality)
|
|
307
|
+
if (maskBytes) form.set('mask', new Blob([maskBytes], { type: 'image/png' }), 'mask.png')
|
|
212
308
|
for (const [index, raw] of input.images.entries()) {
|
|
213
309
|
const image = raw
|
|
214
|
-
const bytes =
|
|
310
|
+
const bytes = decoded[index]
|
|
311
|
+
if (bytes === undefined) throw badRequest('images', 'encoding')
|
|
215
312
|
const extension = image.mime === 'image/jpeg' ? 'jpg' : image.mime.slice(6)
|
|
216
313
|
form.append(
|
|
217
314
|
input.images.length === 1 ? 'image' : 'image[]',
|
|
@@ -221,7 +318,20 @@ export async function generateImage(
|
|
|
221
318
|
}
|
|
222
319
|
payload = form
|
|
223
320
|
} else {
|
|
224
|
-
payload = JSON.stringify(
|
|
321
|
+
payload = JSON.stringify(
|
|
322
|
+
capabilities.profile === 'siliconflow'
|
|
323
|
+
? { model: connection.modelId, prompt, image_size: input.size }
|
|
324
|
+
: {
|
|
325
|
+
model: connection.modelId,
|
|
326
|
+
prompt,
|
|
327
|
+
size: input.size,
|
|
328
|
+
n: 1,
|
|
329
|
+
...(capabilities.profile === 'gpt-image'
|
|
330
|
+
? { output_format: 'png', background: 'opaque' }
|
|
331
|
+
: {}),
|
|
332
|
+
...(input.quality === undefined ? {} : { quality: input.quality }),
|
|
333
|
+
},
|
|
334
|
+
)
|
|
225
335
|
}
|
|
226
336
|
const baseHeaders = new Headers(connection.headers)
|
|
227
337
|
// Never allow a custom JSON header to break the platform multipart boundary.
|
|
@@ -235,19 +345,35 @@ export async function generateImage(
|
|
|
235
345
|
if (key !== undefined) headers.set('authorization', `Bearer ${key}`)
|
|
236
346
|
dispatch = 'unknown'
|
|
237
347
|
const response = await abortable(
|
|
238
|
-
http.fetch(target, {
|
|
348
|
+
http.fetch(target, {
|
|
349
|
+
method: 'POST',
|
|
350
|
+
headers,
|
|
351
|
+
body: payload,
|
|
352
|
+
redirect: 'manual',
|
|
353
|
+
signal: active,
|
|
354
|
+
}),
|
|
239
355
|
active,
|
|
240
356
|
)
|
|
241
357
|
if (response.redirected || (response.status >= 300 && response.status < 400)) {
|
|
242
358
|
void response.body?.cancel().catch(() => {})
|
|
243
359
|
throw new KitFailure('upstream', 'Image redirect refused', { status: response.status })
|
|
244
360
|
}
|
|
245
|
-
if (
|
|
246
|
-
const bytes = await bounded(
|
|
361
|
+
if (response.status >= 400 && response.status < 500) dispatch = 'rejected'
|
|
362
|
+
const bytes = await bounded(
|
|
363
|
+
response,
|
|
364
|
+
response.ok ? Math.ceil(maxImageBytes / 3) * 4 + 64 * 1024 : 8192,
|
|
365
|
+
active,
|
|
366
|
+
)
|
|
247
367
|
if (!response.ok) {
|
|
248
368
|
if ([401, 403, 429].includes(response.status) && attempt + 1 < attempts) continue
|
|
249
369
|
// Deliberately never surface provider bodies: they can contain credentials.
|
|
250
|
-
|
|
370
|
+
const code =
|
|
371
|
+
response.status === 401 || response.status === 403
|
|
372
|
+
? 'unauthorized'
|
|
373
|
+
: response.status === 429
|
|
374
|
+
? 'rate_limited'
|
|
375
|
+
: 'upstream'
|
|
376
|
+
throw new KitFailure(code, 'Image provider rejected request', { status: response.status })
|
|
251
377
|
}
|
|
252
378
|
let body: unknown
|
|
253
379
|
try {
|
|
@@ -255,14 +381,30 @@ export async function generateImage(
|
|
|
255
381
|
} catch {
|
|
256
382
|
throw new KitFailure('upstream', 'Invalid image response')
|
|
257
383
|
}
|
|
258
|
-
const
|
|
259
|
-
|
|
384
|
+
const images: unknown = record(body)
|
|
385
|
+
? capabilities.profile === 'siliconflow'
|
|
386
|
+
? body.images
|
|
387
|
+
: body.data
|
|
388
|
+
: undefined
|
|
389
|
+
if (!Array.isArray(images) || images.length !== 1)
|
|
390
|
+
throw new KitFailure('upstream', 'Expected exactly one image')
|
|
391
|
+
const first: unknown = images[0]
|
|
392
|
+
if (
|
|
393
|
+
!record(first) ||
|
|
394
|
+
(typeof first.b64_json === 'string') === (typeof first.url === 'string')
|
|
395
|
+
)
|
|
396
|
+
throw new KitFailure('upstream', 'Invalid image response')
|
|
260
397
|
let imageBytes: Uint8Array
|
|
398
|
+
let expectedMime: string | undefined
|
|
261
399
|
if (typeof first.b64_json === 'string') {
|
|
262
|
-
imageBytes = decodeImage({ mime: 'image/png', data: first.b64_json })
|
|
400
|
+
imageBytes = decodeImage({ mime: 'image/png', data: first.b64_json }, maxImageBytes)
|
|
263
401
|
} else if (typeof first.url === 'string') {
|
|
264
402
|
const image = await abortable(
|
|
265
|
-
http.fetch(downloadTarget(first.url), {
|
|
403
|
+
http.fetch(downloadTarget(first.url), {
|
|
404
|
+
method: 'GET',
|
|
405
|
+
redirect: 'manual',
|
|
406
|
+
signal: active,
|
|
407
|
+
}),
|
|
266
408
|
active,
|
|
267
409
|
)
|
|
268
410
|
if (image.redirected || !image.ok) {
|
|
@@ -270,14 +412,45 @@ export async function generateImage(
|
|
|
270
412
|
throw new KitFailure('upstream', 'Image download failed', { status: image.status })
|
|
271
413
|
}
|
|
272
414
|
const mime = image.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase()
|
|
273
|
-
if (
|
|
415
|
+
if (
|
|
416
|
+
mime &&
|
|
417
|
+
mime !== 'application/octet-stream' &&
|
|
418
|
+
!IMAGE_MIMES.some((allowed) => allowed === mime)
|
|
419
|
+
) {
|
|
274
420
|
void image.body?.cancel().catch(() => {})
|
|
275
421
|
throw badRequest('images', 'mime')
|
|
276
422
|
}
|
|
277
|
-
imageBytes = await bounded(image,
|
|
278
|
-
|
|
423
|
+
imageBytes = await bounded(image, maxImageBytes, active)
|
|
424
|
+
expectedMime = mime && mime !== 'application/octet-stream' ? mime : undefined
|
|
279
425
|
} else throw new KitFailure('upstream', 'No image in response')
|
|
280
|
-
|
|
426
|
+
const checked = validateImageBytes(imageBytes, expectedMime, maxImageBytes)
|
|
427
|
+
const usage = emptyUsage()
|
|
428
|
+
let totalTokens: number | null = null
|
|
429
|
+
if (record(body) && record(body.usage)) {
|
|
430
|
+
const count = (value: unknown): number | null =>
|
|
431
|
+
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null
|
|
432
|
+
totalTokens = count(body.usage.total_tokens)
|
|
433
|
+
usage.inputTokens = count(body.usage.input_tokens ?? body.usage.prompt_tokens)
|
|
434
|
+
usage.outputTokens = count(body.usage.output_tokens ?? body.usage.completion_tokens)
|
|
435
|
+
const inputDetails = body.usage.input_tokens_details ?? body.usage.prompt_tokens_details
|
|
436
|
+
const outputDetails =
|
|
437
|
+
body.usage.output_tokens_details ?? body.usage.completion_tokens_details
|
|
438
|
+
if (record(inputDetails)) {
|
|
439
|
+
usage.cacheReadTokens = count(inputDetails.cached_tokens)
|
|
440
|
+
usage.cacheWriteTokens = count(
|
|
441
|
+
inputDetails.cache_creation_tokens ?? body.usage.cache_creation_input_tokens,
|
|
442
|
+
)
|
|
443
|
+
}
|
|
444
|
+
if (!record(inputDetails))
|
|
445
|
+
usage.cacheWriteTokens = count(body.usage.cache_creation_input_tokens)
|
|
446
|
+
if (record(outputDetails)) usage.reasoningTokens = count(outputDetails.reasoning_tokens)
|
|
447
|
+
}
|
|
448
|
+
return {
|
|
449
|
+
image: { mime: checked.mime, data: encodeImage(imageBytes) },
|
|
450
|
+
revisedPrompt: typeof first.revised_prompt === 'string' ? first.revised_prompt : null,
|
|
451
|
+
usage,
|
|
452
|
+
totalTokens,
|
|
453
|
+
}
|
|
281
454
|
}
|
|
282
455
|
throw new KitFailure('internal', 'Image request has no attempts')
|
|
283
456
|
} catch (error: unknown) {
|
|
@@ -288,7 +461,7 @@ export async function generateImage(
|
|
|
288
461
|
: new KitFailure('network', 'Image provider unreachable')
|
|
289
462
|
throw new ImageFailure(failure.code, failure.message, dispatch, failure.details)
|
|
290
463
|
} finally {
|
|
291
|
-
clearTimeout(timer)
|
|
464
|
+
clearTimeout(timer ?? 0)
|
|
292
465
|
signal.removeEventListener('abort', cancel)
|
|
293
466
|
}
|
|
294
467
|
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { badRequest } from '../error.js'
|
|
2
|
+
|
|
3
|
+
const invalid = (): never => {
|
|
4
|
+
throw badRequest('images', 'encoding')
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** Structural image validation shared by independent generation and host storage adapters. */
|
|
8
|
+
export function validateImageBytes(
|
|
9
|
+
bytes: Uint8Array,
|
|
10
|
+
expectedMime?: string,
|
|
11
|
+
maxBytes = 4 * 1024 * 1024,
|
|
12
|
+
): { mime: string; width: number; height: number; alpha: boolean } {
|
|
13
|
+
if (!bytes.length) return invalid()
|
|
14
|
+
if (bytes.length > maxBytes) throw badRequest('images', 'size', maxBytes)
|
|
15
|
+
const signature = (start: number, end: number): string =>
|
|
16
|
+
String.fromCharCode(...bytes.subarray(start, end))
|
|
17
|
+
const mime =
|
|
18
|
+
bytes[0] === 137 && signature(1, 4) === 'PNG'
|
|
19
|
+
? 'image/png'
|
|
20
|
+
: bytes[0] === 255 && bytes[1] === 216
|
|
21
|
+
? 'image/jpeg'
|
|
22
|
+
: signature(0, 4) === 'RIFF' && signature(8, 12) === 'WEBP'
|
|
23
|
+
? 'image/webp'
|
|
24
|
+
: ['GIF87a', 'GIF89a'].includes(signature(0, 6))
|
|
25
|
+
? 'image/gif'
|
|
26
|
+
: undefined
|
|
27
|
+
if (!mime || (expectedMime !== undefined && expectedMime !== mime))
|
|
28
|
+
throw badRequest('images', 'mime')
|
|
29
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
30
|
+
const tag = (offset: number, text: string): boolean => {
|
|
31
|
+
for (let index = 0; index < text.length; index++)
|
|
32
|
+
if (bytes[offset + index] !== text.charCodeAt(index)) return false
|
|
33
|
+
return true
|
|
34
|
+
}
|
|
35
|
+
let width = 0,
|
|
36
|
+
height = 0,
|
|
37
|
+
alpha = false
|
|
38
|
+
if (mime === 'image/png') {
|
|
39
|
+
if (bytes.length < 57 || ![137, 80, 78, 71, 13, 10, 26, 10].every((v, i) => bytes[i] === v))
|
|
40
|
+
return invalid()
|
|
41
|
+
let offset = 8,
|
|
42
|
+
first = true,
|
|
43
|
+
data = false,
|
|
44
|
+
end = false
|
|
45
|
+
while (offset + 12 <= bytes.length) {
|
|
46
|
+
const size = view.getUint32(offset)
|
|
47
|
+
if (size > bytes.length - offset - 12) return invalid()
|
|
48
|
+
const kind = offset + 4
|
|
49
|
+
if (first) {
|
|
50
|
+
if (!tag(kind, 'IHDR') || size !== 13) return invalid()
|
|
51
|
+
width = view.getUint32(offset + 8)
|
|
52
|
+
height = view.getUint32(offset + 12)
|
|
53
|
+
const depth = bytes[offset + 16],
|
|
54
|
+
color = bytes[offset + 17]
|
|
55
|
+
if (
|
|
56
|
+
depth === undefined ||
|
|
57
|
+
color === undefined ||
|
|
58
|
+
![0, 2, 3, 4, 6].includes(color) ||
|
|
59
|
+
!(color === 0 ? [1, 2, 4, 8, 16] : color === 3 ? [1, 2, 4, 8] : [8, 16]).includes(
|
|
60
|
+
depth,
|
|
61
|
+
) ||
|
|
62
|
+
bytes[offset + 18] !== 0 ||
|
|
63
|
+
bytes[offset + 19] !== 0 ||
|
|
64
|
+
![0, 1].includes(bytes[offset + 20] ?? -1)
|
|
65
|
+
)
|
|
66
|
+
return invalid()
|
|
67
|
+
alpha = color === 4 || color === 6
|
|
68
|
+
first = false
|
|
69
|
+
} else if (tag(kind, 'IHDR')) return invalid()
|
|
70
|
+
if (tag(kind, 'tRNS')) alpha = true
|
|
71
|
+
if (tag(kind, 'IDAT') && size > 0) data = true
|
|
72
|
+
offset += size + 12
|
|
73
|
+
if (tag(kind, 'IEND')) {
|
|
74
|
+
if (size !== 0 || offset !== bytes.length) return invalid()
|
|
75
|
+
end = true
|
|
76
|
+
break
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (!data || !end) return invalid()
|
|
80
|
+
} else if (mime === 'image/jpeg') {
|
|
81
|
+
if (bytes.length < 12 || bytes[0] !== 255 || bytes[1] !== 216) return invalid()
|
|
82
|
+
let offset = 2,
|
|
83
|
+
scan = false,
|
|
84
|
+
end = false
|
|
85
|
+
while (offset < bytes.length) {
|
|
86
|
+
if (bytes[offset++] !== 255) return invalid()
|
|
87
|
+
while (bytes[offset] === 255) offset++
|
|
88
|
+
const marker = bytes[offset++]
|
|
89
|
+
if (marker === 217) {
|
|
90
|
+
end = offset === bytes.length
|
|
91
|
+
break
|
|
92
|
+
}
|
|
93
|
+
if (marker === undefined || marker === 0 || marker === 216 || offset + 2 > bytes.length)
|
|
94
|
+
return invalid()
|
|
95
|
+
if (marker >= 208 && marker <= 215) continue
|
|
96
|
+
const size = view.getUint16(offset)
|
|
97
|
+
if (size < 2 || offset + size > bytes.length) return invalid()
|
|
98
|
+
if ([192, 193, 194, 195, 197, 198, 199, 201, 202, 203, 205, 206, 207].includes(marker)) {
|
|
99
|
+
if (size < 8 || width !== 0) return invalid()
|
|
100
|
+
height = view.getUint16(offset + 3)
|
|
101
|
+
width = view.getUint16(offset + 5)
|
|
102
|
+
}
|
|
103
|
+
offset += size
|
|
104
|
+
if (marker === 218) {
|
|
105
|
+
scan = true
|
|
106
|
+
while (offset < bytes.length) {
|
|
107
|
+
if (bytes[offset] !== 255) {
|
|
108
|
+
offset++
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
if (
|
|
112
|
+
bytes[offset + 1] === 0 ||
|
|
113
|
+
((bytes[offset + 1] ?? 0) >= 208 && (bytes[offset + 1] ?? 0) <= 215)
|
|
114
|
+
) {
|
|
115
|
+
offset += 2
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
break
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (!scan || !end) return invalid()
|
|
123
|
+
} else if (mime === 'image/webp') {
|
|
124
|
+
if (
|
|
125
|
+
bytes.length < 26 ||
|
|
126
|
+
!tag(0, 'RIFF') ||
|
|
127
|
+
!tag(8, 'WEBP') ||
|
|
128
|
+
view.getUint32(4, true) + 8 !== bytes.length
|
|
129
|
+
)
|
|
130
|
+
return invalid()
|
|
131
|
+
let offset = 12,
|
|
132
|
+
image = false,
|
|
133
|
+
extended = false,
|
|
134
|
+
animated = false,
|
|
135
|
+
animationHeader = false
|
|
136
|
+
while (offset + 8 <= bytes.length) {
|
|
137
|
+
const size = view.getUint32(offset + 4, true),
|
|
138
|
+
body = offset + 8
|
|
139
|
+
if (size > bytes.length - body) return invalid()
|
|
140
|
+
if (tag(offset, 'VP8X')) {
|
|
141
|
+
if (offset !== 12 || size !== 10) return invalid()
|
|
142
|
+
extended = true
|
|
143
|
+
alpha = ((bytes[body] ?? 0) & 16) !== 0
|
|
144
|
+
animated = ((bytes[body] ?? 0) & 2) !== 0
|
|
145
|
+
width =
|
|
146
|
+
1 +
|
|
147
|
+
(bytes[body + 4] ?? 0) +
|
|
148
|
+
((bytes[body + 5] ?? 0) << 8) +
|
|
149
|
+
((bytes[body + 6] ?? 0) << 16)
|
|
150
|
+
height =
|
|
151
|
+
1 +
|
|
152
|
+
(bytes[body + 7] ?? 0) +
|
|
153
|
+
((bytes[body + 8] ?? 0) << 8) +
|
|
154
|
+
((bytes[body + 9] ?? 0) << 16)
|
|
155
|
+
} else if (tag(offset, 'ANIM')) {
|
|
156
|
+
if (!animated || size !== 6 || animationHeader) return invalid()
|
|
157
|
+
animationHeader = true
|
|
158
|
+
} else if (tag(offset, 'ANMF')) {
|
|
159
|
+
if (!animated || !animationHeader || size < 24) return invalid()
|
|
160
|
+
const u24 = (at: number): number =>
|
|
161
|
+
(bytes[at] ?? 0) + ((bytes[at + 1] ?? 0) << 8) + ((bytes[at + 2] ?? 0) << 16)
|
|
162
|
+
const x = u24(body) * 2,
|
|
163
|
+
y = u24(body + 3) * 2,
|
|
164
|
+
w = u24(body + 6) + 1,
|
|
165
|
+
h = u24(body + 9) + 1
|
|
166
|
+
if (x + w > width || y + h > height || ((bytes[body + 15] ?? 0) & 252) !== 0)
|
|
167
|
+
return invalid()
|
|
168
|
+
let frame = body + 16,
|
|
169
|
+
pixels = false
|
|
170
|
+
while (frame + 8 <= body + size) {
|
|
171
|
+
const length = view.getUint32(frame + 4, true),
|
|
172
|
+
data = frame + 8
|
|
173
|
+
if (data + length > body + size) return invalid()
|
|
174
|
+
if (tag(frame, 'VP8 ')) {
|
|
175
|
+
if (
|
|
176
|
+
pixels ||
|
|
177
|
+
length < 10 ||
|
|
178
|
+
((bytes[data] ?? 1) & 1) !== 0 ||
|
|
179
|
+
!tag(data + 3, '\u009d\u0001\u002a') ||
|
|
180
|
+
(view.getUint16(data + 6, true) & 16383) !== w ||
|
|
181
|
+
(view.getUint16(data + 8, true) & 16383) !== h
|
|
182
|
+
)
|
|
183
|
+
return invalid()
|
|
184
|
+
pixels = true
|
|
185
|
+
} else if (tag(frame, 'VP8L')) {
|
|
186
|
+
if (pixels || length < 5 || bytes[data] !== 47) return invalid()
|
|
187
|
+
const bits = view.getUint32(data + 1, true)
|
|
188
|
+
if (bits >>> 29 !== 0 || (bits & 16383) + 1 !== w || ((bits >>> 14) & 16383) + 1 !== h)
|
|
189
|
+
return invalid()
|
|
190
|
+
alpha ||= ((bits >>> 28) & 1) !== 0
|
|
191
|
+
pixels = true
|
|
192
|
+
} else if (!tag(frame, 'ALPH')) return invalid()
|
|
193
|
+
frame = data + length + (length & 1)
|
|
194
|
+
}
|
|
195
|
+
if (!pixels || frame !== body + size) return invalid()
|
|
196
|
+
image = true
|
|
197
|
+
} else if (tag(offset, 'VP8 ') || tag(offset, 'VP8L')) {
|
|
198
|
+
if (animated) return invalid()
|
|
199
|
+
if (image) return invalid()
|
|
200
|
+
image = true
|
|
201
|
+
let w: number, h: number
|
|
202
|
+
if (tag(offset, 'VP8 ')) {
|
|
203
|
+
if (size < 10 || ((bytes[body] ?? 1) & 1) !== 0 || !tag(body + 3, '\u009d\u0001\u002a'))
|
|
204
|
+
return invalid()
|
|
205
|
+
w = view.getUint16(body + 6, true) & 16383
|
|
206
|
+
h = view.getUint16(body + 8, true) & 16383
|
|
207
|
+
} else {
|
|
208
|
+
if (size < 5 || bytes[body] !== 47) return invalid()
|
|
209
|
+
const bits = view.getUint32(body + 1, true)
|
|
210
|
+
if (bits >>> 29 !== 0) return invalid()
|
|
211
|
+
w = (bits & 16383) + 1
|
|
212
|
+
h = ((bits >>> 14) & 16383) + 1
|
|
213
|
+
alpha ||= ((bits >>> 28) & 1) !== 0
|
|
214
|
+
}
|
|
215
|
+
if (extended && (w !== width || h !== height)) return invalid()
|
|
216
|
+
width = w
|
|
217
|
+
height = h
|
|
218
|
+
}
|
|
219
|
+
offset = body + size + (size & 1)
|
|
220
|
+
}
|
|
221
|
+
if (!image || offset !== bytes.length) return invalid()
|
|
222
|
+
} else if (mime === 'image/gif') {
|
|
223
|
+
if (bytes.length < 14 || (!tag(0, 'GIF87a') && !tag(0, 'GIF89a'))) return invalid()
|
|
224
|
+
width = view.getUint16(6, true)
|
|
225
|
+
height = view.getUint16(8, true)
|
|
226
|
+
let offset = 13
|
|
227
|
+
const packed = bytes[10] ?? 0
|
|
228
|
+
if (packed & 128) offset += 3 * (1 << ((packed & 7) + 1))
|
|
229
|
+
const blocks = (): void => {
|
|
230
|
+
while (true) {
|
|
231
|
+
if (offset >= bytes.length) return invalid()
|
|
232
|
+
const size = bytes[offset++] ?? 0
|
|
233
|
+
if (!size) return
|
|
234
|
+
if (size > bytes.length - offset) return invalid()
|
|
235
|
+
offset += size
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
let frames = 0,
|
|
239
|
+
end = false
|
|
240
|
+
while (offset < bytes.length) {
|
|
241
|
+
const marker = bytes[offset++]
|
|
242
|
+
if (marker === 59) {
|
|
243
|
+
end = offset === bytes.length
|
|
244
|
+
break
|
|
245
|
+
}
|
|
246
|
+
if (marker === 33) {
|
|
247
|
+
if (offset >= bytes.length) return invalid()
|
|
248
|
+
const label = bytes[offset++]
|
|
249
|
+
if (label === 249) {
|
|
250
|
+
if (bytes[offset] !== 4 || offset + 6 > bytes.length) return invalid()
|
|
251
|
+
alpha ||= ((bytes[offset + 1] ?? 0) & 1) !== 0
|
|
252
|
+
}
|
|
253
|
+
blocks()
|
|
254
|
+
} else if (marker === 44) {
|
|
255
|
+
if (offset + 9 > bytes.length) return invalid()
|
|
256
|
+
const x = view.getUint16(offset, true),
|
|
257
|
+
y = view.getUint16(offset + 2, true)
|
|
258
|
+
const w = view.getUint16(offset + 4, true),
|
|
259
|
+
h = view.getUint16(offset + 6, true)
|
|
260
|
+
if (!w || !h || x + w > width || y + h > height) return invalid()
|
|
261
|
+
const flags = bytes[offset + 8] ?? 0
|
|
262
|
+
offset += 9
|
|
263
|
+
if (flags & 128) offset += 3 * (1 << ((flags & 7) + 1))
|
|
264
|
+
const codeSize = bytes[offset++]
|
|
265
|
+
if (codeSize === undefined || codeSize < 2 || codeSize > 8 || !bytes[offset])
|
|
266
|
+
return invalid()
|
|
267
|
+
blocks()
|
|
268
|
+
frames++
|
|
269
|
+
} else return invalid()
|
|
270
|
+
}
|
|
271
|
+
if (!frames || !end) return invalid()
|
|
272
|
+
} else return invalid()
|
|
273
|
+
if (width < 1 || height < 1 || width * height > 40_000_000)
|
|
274
|
+
throw badRequest('images', 'dimensions')
|
|
275
|
+
return { mime, width, height, alpha }
|
|
276
|
+
}
|