pi-openai-toolkit 0.20.8 → 0.21.0
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/LICENSE +21 -21
- package/NOTICE +83 -83
- package/README.md +8 -26
- package/README.zh.md +8 -26
- package/config.schema.json +278 -366
- package/docs/configuration.md +294 -310
- package/docs/internals.md +166 -175
- package/package.json +3 -13
- package/src/compact-client.ts +412 -389
- package/src/compaction-errors.ts +96 -0
- package/src/compaction-projection.ts +35 -35
- package/src/config/legacy.ts +519 -717
- package/src/config/migration.ts +103 -100
- package/src/config/notifications.ts +36 -28
- package/src/config/policy.ts +10 -18
- package/src/config/v2.ts +1167 -1380
- package/src/config.ts +310 -319
- package/src/context-management/codex-provider.ts +270 -270
- package/src/context-management/history-notes.ts +357 -357
- package/src/context-management/manual-compact.ts +0 -11
- package/src/context-management/tools.ts +371 -371
- package/src/context-management/types.ts +168 -168
- package/src/context-management/window-manager.ts +801 -801
- package/src/debug.ts +238 -237
- package/src/deferred-tool-carryover.ts +282 -282
- package/src/details-store.ts +151 -151
- package/src/extension-runtime.ts +1662 -1616
- package/src/image-generation/extension.ts +189 -189
- package/src/image-generation/protocol.ts +579 -579
- package/src/image-generation/types.ts +300 -300
- package/src/payload-rewrite.ts +515 -518
- package/src/pi-context-hook.ts +117 -117
- package/src/remote-v2-client.ts +811 -814
- package/src/request-context-cache.ts +155 -155
- package/src/responses-headers.ts +180 -180
- package/src/runtime.ts +865 -832
- package/src/serializer.ts +882 -882
- package/src/supported-environment.ts +27 -27
- package/src/types.ts +565 -648
- package/src/web-search/alpha-client.ts +876 -876
- package/src/web-search/extension.ts +530 -530
- package/src/web-search/payload.ts +299 -299
- package/src/web-search/prompt.ts +60 -60
- package/src/web-search/types.ts +166 -166
- package/tsconfig.check.json +50 -54
- package/src/auto-mode/classifier.ts +0 -161
- package/src/auto-mode/extension.ts +0 -691
- package/src/auto-mode/model-selection-guard.ts +0 -52
- package/src/auto-mode/policy.ts +0 -146
- package/src/auto-mode/prompt.ts +0 -279
- package/src/auto-mode/reviewer.ts +0 -325
- package/src/auto-mode/tool-review-tui.ts +0 -211
- package/src/auto-mode/transcript.ts +0 -135
- package/src/auto-mode/types.ts +0 -137
|
@@ -1,579 +1,579 @@
|
|
|
1
|
-
import { DEFAULT_IMAGE_GENERATION_MODELS } from "../types";
|
|
2
|
-
import {
|
|
3
|
-
IMAGE_GENERATION_MIME_TYPES,
|
|
4
|
-
IMAGE_GENERATION_QUALITIES,
|
|
5
|
-
IMAGE_GENERATION_SIZES,
|
|
6
|
-
MAX_GENERATED_IMAGE_BYTES,
|
|
7
|
-
MAX_IMAGE_BATCH_SIZE,
|
|
8
|
-
MAX_IMAGE_DIAGNOSTIC_CHARS,
|
|
9
|
-
MAX_IMAGE_DIMENSION,
|
|
10
|
-
MAX_IMAGE_IDENTIFIER_CHARS,
|
|
11
|
-
MAX_IMAGE_MODEL_ID_CHARS,
|
|
12
|
-
MAX_IMAGE_PATH_CHARS,
|
|
13
|
-
MAX_IMAGE_PROMPT_CHARS,
|
|
14
|
-
MAX_REFERENCE_IMAGE_COUNT,
|
|
15
|
-
ImageGenerationError,
|
|
16
|
-
sanitizeImageDiagnostic,
|
|
17
|
-
type GenerateImageParams,
|
|
18
|
-
type ImageGenerationMimeType,
|
|
19
|
-
type NormalizedGenerateImageParams,
|
|
20
|
-
type ParsedGeneratedImage,
|
|
21
|
-
type PreparedReferenceImage,
|
|
22
|
-
type ReferenceImageMimeType,
|
|
23
|
-
} from "./types";
|
|
24
|
-
|
|
25
|
-
const MAX_DISPLAYED_IMAGE_MODELS_IN_ERROR = 5;
|
|
26
|
-
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
27
|
-
const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
28
|
-
const DATA_URL_PATTERN = /^data:image\/[a-z0-9.+-]+;base64,(.*)$/is;
|
|
29
|
-
|
|
30
|
-
export type ImageGenerationRequestBody = {
|
|
31
|
-
action: NormalizedGenerateImageParams["action"];
|
|
32
|
-
model: string;
|
|
33
|
-
prompt: string;
|
|
34
|
-
n: 1;
|
|
35
|
-
size: NormalizedGenerateImageParams["size"];
|
|
36
|
-
quality: NormalizedGenerateImageParams["quality"];
|
|
37
|
-
references: readonly PreparedReferenceImage[];
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
export type ParsedImageResponseResult =
|
|
41
|
-
| { ok: true; image: ParsedGeneratedImage }
|
|
42
|
-
| {
|
|
43
|
-
ok: false;
|
|
44
|
-
reason: "malformed-response" | "no-image" | "request-rejected" | "oversized-response";
|
|
45
|
-
errorMessage: string;
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
49
|
-
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function normalizedOptionalString(value: unknown, maxChars: number): string | undefined {
|
|
53
|
-
if (typeof value !== "string") return undefined;
|
|
54
|
-
const trimmed = value.trim();
|
|
55
|
-
return trimmed ? trimmed.slice(0, maxChars) : undefined;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function normalizePathList(value: unknown): string[] {
|
|
59
|
-
if (value === null || value === undefined) return [];
|
|
60
|
-
if (!Array.isArray(value)) {
|
|
61
|
-
throw new ImageGenerationError(
|
|
62
|
-
"invalid-parameters",
|
|
63
|
-
"referenceImagePaths must be an array of path strings or null.",
|
|
64
|
-
);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const normalized: string[] = [];
|
|
68
|
-
for (const rawPath of value) {
|
|
69
|
-
if (typeof rawPath !== "string") {
|
|
70
|
-
throw new ImageGenerationError(
|
|
71
|
-
"invalid-parameters",
|
|
72
|
-
"referenceImagePaths must contain only path strings.",
|
|
73
|
-
);
|
|
74
|
-
}
|
|
75
|
-
const trimmed = rawPath.trim();
|
|
76
|
-
if (!trimmed) continue;
|
|
77
|
-
if (trimmed.length > MAX_IMAGE_PATH_CHARS) {
|
|
78
|
-
throw new ImageGenerationError(
|
|
79
|
-
"invalid-parameters",
|
|
80
|
-
`Each reference image path must be at most ${MAX_IMAGE_PATH_CHARS} characters.`,
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
|
-
normalized.push(trimmed);
|
|
84
|
-
if (normalized.length > MAX_REFERENCE_IMAGE_COUNT) {
|
|
85
|
-
throw new ImageGenerationError(
|
|
86
|
-
"invalid-parameters",
|
|
87
|
-
`referenceImagePaths must contain at most ${MAX_REFERENCE_IMAGE_COUNT} paths.`,
|
|
88
|
-
);
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
return normalized;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function normalizeOutputPath(value: unknown): string | undefined {
|
|
95
|
-
if (value === null || value === undefined) return undefined;
|
|
96
|
-
if (typeof value !== "string") {
|
|
97
|
-
throw new ImageGenerationError(
|
|
98
|
-
"invalid-parameters",
|
|
99
|
-
"outputPath must be a path string or null.",
|
|
100
|
-
);
|
|
101
|
-
}
|
|
102
|
-
const trimmed = value.trim();
|
|
103
|
-
if (!trimmed) return undefined;
|
|
104
|
-
if (trimmed.length > MAX_IMAGE_PATH_CHARS) {
|
|
105
|
-
throw new ImageGenerationError(
|
|
106
|
-
"invalid-parameters",
|
|
107
|
-
`outputPath must be at most ${MAX_IMAGE_PATH_CHARS} characters when provided.`,
|
|
108
|
-
);
|
|
109
|
-
}
|
|
110
|
-
return trimmed;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function normalizeRequestedModel(value: unknown): string | undefined {
|
|
114
|
-
if (value === null || value === undefined) return undefined;
|
|
115
|
-
if (typeof value !== "string") {
|
|
116
|
-
throw new ImageGenerationError("invalid-parameters", "model must be a model id string or null.");
|
|
117
|
-
}
|
|
118
|
-
const trimmed = value.trim();
|
|
119
|
-
if (!trimmed) return undefined;
|
|
120
|
-
if (trimmed.length > MAX_IMAGE_MODEL_ID_CHARS) {
|
|
121
|
-
throw new ImageGenerationError(
|
|
122
|
-
"invalid-parameters",
|
|
123
|
-
`model must be at most ${MAX_IMAGE_MODEL_ID_CHARS} characters when provided.`,
|
|
124
|
-
);
|
|
125
|
-
}
|
|
126
|
-
return trimmed;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function normalizeBatchSize(value: unknown): number {
|
|
130
|
-
if (value === null || value === undefined) return 1;
|
|
131
|
-
if (
|
|
132
|
-
typeof value !== "number" ||
|
|
133
|
-
!Number.isInteger(value) ||
|
|
134
|
-
value < 1 ||
|
|
135
|
-
value > MAX_IMAGE_BATCH_SIZE
|
|
136
|
-
) {
|
|
137
|
-
throw new ImageGenerationError(
|
|
138
|
-
"invalid-parameters",
|
|
139
|
-
`batchSize must be an integer from 1 to ${MAX_IMAGE_BATCH_SIZE}.`,
|
|
140
|
-
);
|
|
141
|
-
}
|
|
142
|
-
return value;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Configured model ids are only trusted as far as the shared bounds: unusable entries are
|
|
147
|
-
* dropped, and an empty or malformed list falls back to the shipped default model.
|
|
148
|
-
*/
|
|
149
|
-
function usableImageModels(models: readonly string[] | undefined): string[] {
|
|
150
|
-
const usable: string[] = [];
|
|
151
|
-
for (const rawModel of Array.isArray(models) ? models : []) {
|
|
152
|
-
if (typeof rawModel !== "string") continue;
|
|
153
|
-
const trimmed = rawModel.trim();
|
|
154
|
-
if (!trimmed || trimmed.length > MAX_IMAGE_MODEL_ID_CHARS) continue;
|
|
155
|
-
if (!usable.includes(trimmed)) usable.push(trimmed);
|
|
156
|
-
}
|
|
157
|
-
return usable.length > 0 ? usable : [...DEFAULT_IMAGE_GENERATION_MODELS];
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Resolve the independent Images API model for one call. An omitted request selects the
|
|
162
|
-
* explicit default when supplied (v2), or the first configured model for legacy callers.
|
|
163
|
-
* An explicit request must match a configured id exactly before any paid dispatch.
|
|
164
|
-
*/
|
|
165
|
-
function formatAvailableImageModels(models: readonly string[]): string {
|
|
166
|
-
const visible = models.slice(0, MAX_DISPLAYED_IMAGE_MODELS_IN_ERROR);
|
|
167
|
-
const remaining = models.length - visible.length;
|
|
168
|
-
return remaining > 0 ? `${visible.join(", ")}, … and ${remaining} more` : visible.join(", ");
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
export function selectImageGenerationModel(args: {
|
|
172
|
-
requestedModel?: string;
|
|
173
|
-
configuredModels?: readonly string[];
|
|
174
|
-
defaultModel?: string;
|
|
175
|
-
}): string {
|
|
176
|
-
const models = usableImageModels(args.configuredModels);
|
|
177
|
-
if (args.defaultModel !== undefined && (!args.configuredModels?.length ||
|
|
178
|
-
!args.configuredModels.every((model) => typeof model === "string" && model.trim().length > 0 && model.trim().length <= MAX_IMAGE_MODEL_ID_CHARS) ||
|
|
179
|
-
!models.includes(args.defaultModel))) {
|
|
180
|
-
throw new ImageGenerationError("invalid-parameters", "The configured default image model must be in the nonempty allowedModels list.");
|
|
181
|
-
}
|
|
182
|
-
const requested = typeof args.requestedModel === "string" ? args.requestedModel.trim() : "";
|
|
183
|
-
if (!requested) return args.defaultModel ?? models[0]!;
|
|
184
|
-
if (models.includes(requested)) return requested;
|
|
185
|
-
throw new ImageGenerationError(
|
|
186
|
-
"invalid-parameters",
|
|
187
|
-
`Unknown image generation model "${requested.slice(0, MAX_IMAGE_MODEL_ID_CHARS)}". ` +
|
|
188
|
-
`Configure it in imageGeneration.allowedModels (legacy: imageGeneration.models) first; available models: ${formatAvailableImageModels(models)}.`,
|
|
189
|
-
);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
export function normalizeGenerateImageParams(
|
|
193
|
-
params: GenerateImageParams,
|
|
194
|
-
): NormalizedGenerateImageParams {
|
|
195
|
-
const prompt = typeof params.prompt === "string" ? params.prompt.trim() : "";
|
|
196
|
-
if (!prompt || prompt.length > MAX_IMAGE_PROMPT_CHARS) {
|
|
197
|
-
throw new ImageGenerationError(
|
|
198
|
-
"invalid-parameters",
|
|
199
|
-
`prompt must be 1-${MAX_IMAGE_PROMPT_CHARS} characters.`,
|
|
200
|
-
);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
const size = params.size ?? "auto";
|
|
204
|
-
if (!(IMAGE_GENERATION_SIZES as readonly string[]).includes(size)) {
|
|
205
|
-
throw new ImageGenerationError(
|
|
206
|
-
"invalid-parameters",
|
|
207
|
-
`Unsupported image size: ${String(size)}.`,
|
|
208
|
-
);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
const quality = params.quality ?? "auto";
|
|
212
|
-
if (!(IMAGE_GENERATION_QUALITIES as readonly string[]).includes(quality)) {
|
|
213
|
-
throw new ImageGenerationError(
|
|
214
|
-
"invalid-parameters",
|
|
215
|
-
`Unsupported image quality: ${String(quality)}.`,
|
|
216
|
-
);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
const referenceImagePaths = normalizePathList(params.referenceImagePaths);
|
|
220
|
-
const outputPath = normalizeOutputPath(params.outputPath);
|
|
221
|
-
const batchSize = normalizeBatchSize(params.batchSize);
|
|
222
|
-
if (batchSize > 1 && outputPath !== undefined) {
|
|
223
|
-
throw new ImageGenerationError(
|
|
224
|
-
"invalid-parameters",
|
|
225
|
-
"outputPath cannot be used when batchSize is greater than 1; use the generated artifacts instead.",
|
|
226
|
-
);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
return {
|
|
230
|
-
prompt,
|
|
231
|
-
referenceImagePaths,
|
|
232
|
-
outputPath,
|
|
233
|
-
size,
|
|
234
|
-
quality,
|
|
235
|
-
model: normalizeRequestedModel(params.model),
|
|
236
|
-
batchSize,
|
|
237
|
-
action: referenceImagePaths.length > 0 ? "edit" : "generate",
|
|
238
|
-
};
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
export function buildImageGenerationRequest(args: {
|
|
242
|
-
imageModel: string;
|
|
243
|
-
params: NormalizedGenerateImageParams;
|
|
244
|
-
references: readonly PreparedReferenceImage[];
|
|
245
|
-
}): ImageGenerationRequestBody {
|
|
246
|
-
const imageModel = typeof args.imageModel === "string" ? args.imageModel.trim() : "";
|
|
247
|
-
if (!imageModel || imageModel.length > MAX_IMAGE_MODEL_ID_CHARS) {
|
|
248
|
-
throw new ImageGenerationError(
|
|
249
|
-
"invalid-parameters",
|
|
250
|
-
"A configured image generation model is required.",
|
|
251
|
-
);
|
|
252
|
-
}
|
|
253
|
-
return {
|
|
254
|
-
action: args.params.action,
|
|
255
|
-
model: imageModel,
|
|
256
|
-
prompt: args.params.prompt,
|
|
257
|
-
n: 1,
|
|
258
|
-
size: args.params.size,
|
|
259
|
-
quality: args.params.quality,
|
|
260
|
-
references: args.references,
|
|
261
|
-
};
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
function hasPngEndChunk(buffer: Buffer): boolean {
|
|
265
|
-
return (
|
|
266
|
-
buffer.length >= 33 &&
|
|
267
|
-
buffer.readUInt32BE(buffer.length - 12) === 0 &&
|
|
268
|
-
buffer.toString("ascii", buffer.length - 8, buffer.length - 4) === "IEND"
|
|
269
|
-
);
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
export function detectReferenceImageMimeType(bytes: Uint8Array): ReferenceImageMimeType | undefined {
|
|
273
|
-
return readImageMetadata(bytes)?.mimeType;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
export function isValidPng(bytes: Uint8Array): boolean {
|
|
277
|
-
return bytes.byteLength >= PNG_SIGNATURE.length && Buffer.from(bytes).subarray(0, 8).equals(PNG_SIGNATURE);
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
export function readPngDimensions(bytes: Uint8Array): { width: number; height: number } | undefined {
|
|
281
|
-
const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
282
|
-
if (!isValidPng(buffer) || buffer.length < 24) return undefined;
|
|
283
|
-
if (buffer.readUInt32BE(8) !== 13 || buffer.toString("ascii", 12, 16) !== "IHDR") return undefined;
|
|
284
|
-
const width = buffer.readUInt32BE(16);
|
|
285
|
-
const height = buffer.readUInt32BE(20);
|
|
286
|
-
if (
|
|
287
|
-
width <= 0 ||
|
|
288
|
-
height <= 0 ||
|
|
289
|
-
width > MAX_IMAGE_DIMENSION ||
|
|
290
|
-
height > MAX_IMAGE_DIMENSION
|
|
291
|
-
) return undefined;
|
|
292
|
-
return { width, height };
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
function boundedDimensions(width: number, height: number): { width: number; height: number } | undefined {
|
|
296
|
-
if (
|
|
297
|
-
!Number.isInteger(width) ||
|
|
298
|
-
!Number.isInteger(height) ||
|
|
299
|
-
width <= 0 ||
|
|
300
|
-
height <= 0 ||
|
|
301
|
-
width > MAX_IMAGE_DIMENSION ||
|
|
302
|
-
height > MAX_IMAGE_DIMENSION
|
|
303
|
-
) {
|
|
304
|
-
return undefined;
|
|
305
|
-
}
|
|
306
|
-
return { width, height };
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
const JPEG_DIMENSION_MARKERS = new Set([
|
|
310
|
-
0xc0,
|
|
311
|
-
0xc1,
|
|
312
|
-
0xc2,
|
|
313
|
-
0xc3,
|
|
314
|
-
0xc5,
|
|
315
|
-
0xc6,
|
|
316
|
-
0xc7,
|
|
317
|
-
0xc9,
|
|
318
|
-
0xca,
|
|
319
|
-
0xcb,
|
|
320
|
-
0xcd,
|
|
321
|
-
0xce,
|
|
322
|
-
0xcf,
|
|
323
|
-
]);
|
|
324
|
-
|
|
325
|
-
function readJpegDimensions(bytes: Uint8Array): { width: number; height: number } | undefined {
|
|
326
|
-
const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
327
|
-
if (buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) return undefined;
|
|
328
|
-
let offset = 2;
|
|
329
|
-
while (offset < buffer.length) {
|
|
330
|
-
if (buffer[offset] !== 0xff) {
|
|
331
|
-
offset += 1;
|
|
332
|
-
continue;
|
|
333
|
-
}
|
|
334
|
-
while (offset < buffer.length && buffer[offset] === 0xff) offset += 1;
|
|
335
|
-
if (offset >= buffer.length) return undefined;
|
|
336
|
-
const marker = buffer[offset++];
|
|
337
|
-
if (marker === 0xd9 || marker === 0xda) return undefined;
|
|
338
|
-
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue;
|
|
339
|
-
if (offset + 2 > buffer.length) return undefined;
|
|
340
|
-
const segmentLength = buffer.readUInt16BE(offset);
|
|
341
|
-
if (segmentLength < 2 || offset + segmentLength > buffer.length) return undefined;
|
|
342
|
-
if (JPEG_DIMENSION_MARKERS.has(marker) && segmentLength >= 7) {
|
|
343
|
-
const height = buffer.readUInt16BE(offset + 3);
|
|
344
|
-
const width = buffer.readUInt16BE(offset + 5);
|
|
345
|
-
return boundedDimensions(width, height);
|
|
346
|
-
}
|
|
347
|
-
offset += segmentLength;
|
|
348
|
-
}
|
|
349
|
-
return undefined;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
function hasJpegEndMarker(bytes: Uint8Array): boolean {
|
|
353
|
-
const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
354
|
-
return buffer.length >= 2 && buffer[buffer.length - 2] === 0xff && buffer[buffer.length - 1] === 0xd9;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
function readWebpDimensions(bytes: Uint8Array): { width: number; height: number } | undefined {
|
|
358
|
-
const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
359
|
-
if (
|
|
360
|
-
buffer.length < 20 ||
|
|
361
|
-
buffer.toString("ascii", 0, 4) !== "RIFF" ||
|
|
362
|
-
buffer.toString("ascii", 8, 12) !== "WEBP"
|
|
363
|
-
) {
|
|
364
|
-
return undefined;
|
|
365
|
-
}
|
|
366
|
-
const declaredSize = buffer.readUInt32LE(4) + 8;
|
|
367
|
-
if (declaredSize !== buffer.length) return undefined;
|
|
368
|
-
let offset = 12;
|
|
369
|
-
while (offset + 8 <= buffer.length) {
|
|
370
|
-
const chunkType = buffer.toString("ascii", offset, offset + 4);
|
|
371
|
-
const chunkSize = buffer.readUInt32LE(offset + 4);
|
|
372
|
-
const dataStart = offset + 8;
|
|
373
|
-
const dataEnd = dataStart + chunkSize;
|
|
374
|
-
if (dataEnd > buffer.length) return undefined;
|
|
375
|
-
if (chunkType === "VP8X" && chunkSize >= 10) {
|
|
376
|
-
const width = 1 + buffer.readUIntLE(dataStart + 4, 3);
|
|
377
|
-
const height = 1 + buffer.readUIntLE(dataStart + 7, 3);
|
|
378
|
-
return boundedDimensions(width, height);
|
|
379
|
-
}
|
|
380
|
-
if (chunkType === "VP8 " && chunkSize >= 10) {
|
|
381
|
-
if (
|
|
382
|
-
buffer[dataStart + 3] === 0x9d &&
|
|
383
|
-
buffer[dataStart + 4] === 0x01 &&
|
|
384
|
-
buffer[dataStart + 5] === 0x2a
|
|
385
|
-
) {
|
|
386
|
-
const width = buffer.readUInt16LE(dataStart + 6) & 0x3fff;
|
|
387
|
-
const height = buffer.readUInt16LE(dataStart + 8) & 0x3fff;
|
|
388
|
-
return boundedDimensions(width, height);
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
if (chunkType === "VP8L" && chunkSize >= 5 && buffer[dataStart] === 0x2f) {
|
|
392
|
-
const width = 1 + ((buffer[dataStart + 1] | (buffer[dataStart + 2] << 8)) & 0x3fff);
|
|
393
|
-
const height = 1 + ((buffer[dataStart + 2] >> 6) | (buffer[dataStart + 3] << 2) | (buffer[dataStart + 4] << 10));
|
|
394
|
-
return boundedDimensions(width, height);
|
|
395
|
-
}
|
|
396
|
-
offset = dataEnd + (chunkSize % 2);
|
|
397
|
-
}
|
|
398
|
-
return undefined;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
function extractBase64(value: string): string {
|
|
402
|
-
const trimmed = value.trim();
|
|
403
|
-
const dataUrl = DATA_URL_PATTERN.exec(trimmed);
|
|
404
|
-
return (dataUrl?.[1] ?? trimmed).trim();
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
function normalizeImageMimeType(value: unknown): ImageGenerationMimeType | undefined {
|
|
408
|
-
if (typeof value !== "string") return undefined;
|
|
409
|
-
const normalized = value.trim().toLowerCase();
|
|
410
|
-
if (normalized === "image/jpg") return "image/jpeg";
|
|
411
|
-
return (IMAGE_GENERATION_MIME_TYPES as readonly string[]).includes(normalized)
|
|
412
|
-
? normalized as ImageGenerationMimeType
|
|
413
|
-
: undefined;
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
export function readImageMetadata(bytes: Uint8Array):
|
|
417
|
-
| { mimeType: ImageGenerationMimeType; width: number; height: number }
|
|
418
|
-
| undefined {
|
|
419
|
-
if (readPngDimensions(bytes) && hasPngEndChunk(Buffer.from(bytes))) {
|
|
420
|
-
const dimensions = readPngDimensions(bytes)!;
|
|
421
|
-
return { mimeType: "image/png", ...dimensions };
|
|
422
|
-
}
|
|
423
|
-
if (hasJpegEndMarker(bytes)) {
|
|
424
|
-
const dimensions = readJpegDimensions(bytes);
|
|
425
|
-
if (dimensions) return { mimeType: "image/jpeg", ...dimensions };
|
|
426
|
-
}
|
|
427
|
-
const dimensions = readWebpDimensions(bytes);
|
|
428
|
-
if (dimensions) return { mimeType: "image/webp", ...dimensions };
|
|
429
|
-
return undefined;
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
export function decodeGeneratedImage(value: string, hintedMimeType?: unknown):
|
|
433
|
-
| { ok: true; bytes: Buffer; mimeType: ImageGenerationMimeType; width: number; height: number }
|
|
434
|
-
| { ok: false; reason: "malformed-response" | "oversized-response"; errorMessage: string } {
|
|
435
|
-
const base64 = extractBase64(value);
|
|
436
|
-
const maxBase64Chars = Math.ceil(MAX_GENERATED_IMAGE_BYTES / 3) * 4;
|
|
437
|
-
if (base64.length > maxBase64Chars) {
|
|
438
|
-
return {
|
|
439
|
-
ok: false,
|
|
440
|
-
reason: "oversized-response",
|
|
441
|
-
errorMessage: "Image generation returned an image larger than the 32 MiB limit.",
|
|
442
|
-
};
|
|
443
|
-
}
|
|
444
|
-
if (!base64 || base64.length % 4 !== 0 || !BASE64_PATTERN.test(base64)) {
|
|
445
|
-
return {
|
|
446
|
-
ok: false,
|
|
447
|
-
reason: "malformed-response",
|
|
448
|
-
errorMessage: "Image generation returned invalid base64 image data.",
|
|
449
|
-
};
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
const bytes = Buffer.from(base64, "base64");
|
|
453
|
-
if (
|
|
454
|
-
bytes.length === 0 ||
|
|
455
|
-
bytes.length > MAX_GENERATED_IMAGE_BYTES ||
|
|
456
|
-
bytes.toString("base64") !== base64
|
|
457
|
-
) {
|
|
458
|
-
bytes.fill(0);
|
|
459
|
-
return {
|
|
460
|
-
ok: false,
|
|
461
|
-
reason: bytes.length > MAX_GENERATED_IMAGE_BYTES ? "oversized-response" : "malformed-response",
|
|
462
|
-
errorMessage: "Image generation returned invalid or oversized image data.",
|
|
463
|
-
};
|
|
464
|
-
}
|
|
465
|
-
const metadata = readImageMetadata(bytes);
|
|
466
|
-
if (!metadata) {
|
|
467
|
-
bytes.fill(0);
|
|
468
|
-
return {
|
|
469
|
-
ok: false,
|
|
470
|
-
reason: "malformed-response",
|
|
471
|
-
errorMessage: "Image generation returned data that is not a supported PNG, JPEG, or WebP image.",
|
|
472
|
-
};
|
|
473
|
-
}
|
|
474
|
-
if (typeof hintedMimeType === "string" && hintedMimeType.trim()) {
|
|
475
|
-
const hinted = normalizeImageMimeType(hintedMimeType);
|
|
476
|
-
if (!hinted || hinted !== metadata.mimeType) {
|
|
477
|
-
bytes.fill(0);
|
|
478
|
-
return {
|
|
479
|
-
ok: false,
|
|
480
|
-
reason: "malformed-response",
|
|
481
|
-
errorMessage: "Image generation returned an image with inconsistent MIME metadata.",
|
|
482
|
-
};
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
return { ok: true, bytes, ...metadata };
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
export function decodeGeneratedPng(value: string):
|
|
489
|
-
| { ok: true; bytes: Buffer; width: number; height: number }
|
|
490
|
-
| { ok: false; reason: "malformed-response" | "oversized-response"; errorMessage: string } {
|
|
491
|
-
const decoded = decodeGeneratedImage(value, "image/png");
|
|
492
|
-
if (!decoded.ok) return decoded;
|
|
493
|
-
return {
|
|
494
|
-
ok: true,
|
|
495
|
-
bytes: decoded.bytes,
|
|
496
|
-
width: decoded.width,
|
|
497
|
-
height: decoded.height,
|
|
498
|
-
};
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
export function extractProviderErrorMessage(value: unknown, fallback: string): string {
|
|
502
|
-
if (!isRecord(value)) return fallback;
|
|
503
|
-
const direct = normalizedOptionalString(value.message, MAX_IMAGE_DIAGNOSTIC_CHARS);
|
|
504
|
-
if (direct) return sanitizeImageDiagnostic(direct, fallback);
|
|
505
|
-
if (isRecord(value.error)) {
|
|
506
|
-
const nested = normalizedOptionalString(value.error.message, MAX_IMAGE_DIAGNOSTIC_CHARS);
|
|
507
|
-
if (nested) return sanitizeImageDiagnostic(nested, fallback);
|
|
508
|
-
}
|
|
509
|
-
return fallback;
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
export function parseImagesApiResponse(value: unknown): ParsedImageResponseResult {
|
|
513
|
-
if (!isRecord(value)) {
|
|
514
|
-
return {
|
|
515
|
-
ok: false,
|
|
516
|
-
reason: "malformed-response",
|
|
517
|
-
errorMessage: "Image generation returned a non-object response.",
|
|
518
|
-
};
|
|
519
|
-
}
|
|
520
|
-
if (!Array.isArray(value.data)) {
|
|
521
|
-
return {
|
|
522
|
-
ok: false,
|
|
523
|
-
reason: "malformed-response",
|
|
524
|
-
errorMessage: "Image generation response did not contain a data array.",
|
|
525
|
-
};
|
|
526
|
-
}
|
|
527
|
-
if (value.data.length === 0) {
|
|
528
|
-
return {
|
|
529
|
-
ok: false,
|
|
530
|
-
reason: "no-image",
|
|
531
|
-
errorMessage: "Image generation completed without a usable image result.",
|
|
532
|
-
};
|
|
533
|
-
}
|
|
534
|
-
if (value.data.length !== 1) {
|
|
535
|
-
return {
|
|
536
|
-
ok: false,
|
|
537
|
-
reason: "malformed-response",
|
|
538
|
-
errorMessage: "Image generation returned more than one image for a single request.",
|
|
539
|
-
};
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
const item = value.data[0];
|
|
543
|
-
if (!isRecord(item) || typeof item.b64_json !== "string" || !item.b64_json.trim()) {
|
|
544
|
-
return {
|
|
545
|
-
ok: false,
|
|
546
|
-
reason: "no-image",
|
|
547
|
-
errorMessage: "Image generation response did not contain base64 image data.",
|
|
548
|
-
};
|
|
549
|
-
}
|
|
550
|
-
const mimeHint = item.mime_type ?? item.mimeType;
|
|
551
|
-
if (mimeHint !== undefined && typeof mimeHint !== "string") {
|
|
552
|
-
return {
|
|
553
|
-
ok: false,
|
|
554
|
-
reason: "malformed-response",
|
|
555
|
-
errorMessage: "Image generation returned invalid MIME metadata.",
|
|
556
|
-
};
|
|
557
|
-
}
|
|
558
|
-
const decoded = decodeGeneratedImage(item.b64_json, mimeHint);
|
|
559
|
-
if (!decoded.ok) return decoded;
|
|
560
|
-
|
|
561
|
-
return {
|
|
562
|
-
ok: true,
|
|
563
|
-
image: {
|
|
564
|
-
bytes: decoded.bytes,
|
|
565
|
-
mimeType: decoded.mimeType,
|
|
566
|
-
imageCallId:
|
|
567
|
-
normalizedOptionalString(item.id, MAX_IMAGE_IDENTIFIER_CHARS) ?? "image_generation_0",
|
|
568
|
-
responseId: normalizedOptionalString(value.id, MAX_IMAGE_IDENTIFIER_CHARS),
|
|
569
|
-
revisedPrompt: normalizedOptionalString(item.revised_prompt, MAX_IMAGE_DIAGNOSTIC_CHARS),
|
|
570
|
-
width: decoded.width,
|
|
571
|
-
height: decoded.height,
|
|
572
|
-
},
|
|
573
|
-
};
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
export const _protocolTest = {
|
|
577
|
-
PNG_SIGNATURE,
|
|
578
|
-
IMAGE_GENERATION_MIME_TYPES,
|
|
579
|
-
};
|
|
1
|
+
import { DEFAULT_IMAGE_GENERATION_MODELS } from "../types";
|
|
2
|
+
import {
|
|
3
|
+
IMAGE_GENERATION_MIME_TYPES,
|
|
4
|
+
IMAGE_GENERATION_QUALITIES,
|
|
5
|
+
IMAGE_GENERATION_SIZES,
|
|
6
|
+
MAX_GENERATED_IMAGE_BYTES,
|
|
7
|
+
MAX_IMAGE_BATCH_SIZE,
|
|
8
|
+
MAX_IMAGE_DIAGNOSTIC_CHARS,
|
|
9
|
+
MAX_IMAGE_DIMENSION,
|
|
10
|
+
MAX_IMAGE_IDENTIFIER_CHARS,
|
|
11
|
+
MAX_IMAGE_MODEL_ID_CHARS,
|
|
12
|
+
MAX_IMAGE_PATH_CHARS,
|
|
13
|
+
MAX_IMAGE_PROMPT_CHARS,
|
|
14
|
+
MAX_REFERENCE_IMAGE_COUNT,
|
|
15
|
+
ImageGenerationError,
|
|
16
|
+
sanitizeImageDiagnostic,
|
|
17
|
+
type GenerateImageParams,
|
|
18
|
+
type ImageGenerationMimeType,
|
|
19
|
+
type NormalizedGenerateImageParams,
|
|
20
|
+
type ParsedGeneratedImage,
|
|
21
|
+
type PreparedReferenceImage,
|
|
22
|
+
type ReferenceImageMimeType,
|
|
23
|
+
} from "./types";
|
|
24
|
+
|
|
25
|
+
const MAX_DISPLAYED_IMAGE_MODELS_IN_ERROR = 5;
|
|
26
|
+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
27
|
+
const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
28
|
+
const DATA_URL_PATTERN = /^data:image\/[a-z0-9.+-]+;base64,(.*)$/is;
|
|
29
|
+
|
|
30
|
+
export type ImageGenerationRequestBody = {
|
|
31
|
+
action: NormalizedGenerateImageParams["action"];
|
|
32
|
+
model: string;
|
|
33
|
+
prompt: string;
|
|
34
|
+
n: 1;
|
|
35
|
+
size: NormalizedGenerateImageParams["size"];
|
|
36
|
+
quality: NormalizedGenerateImageParams["quality"];
|
|
37
|
+
references: readonly PreparedReferenceImage[];
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type ParsedImageResponseResult =
|
|
41
|
+
| { ok: true; image: ParsedGeneratedImage }
|
|
42
|
+
| {
|
|
43
|
+
ok: false;
|
|
44
|
+
reason: "malformed-response" | "no-image" | "request-rejected" | "oversized-response";
|
|
45
|
+
errorMessage: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
49
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function normalizedOptionalString(value: unknown, maxChars: number): string | undefined {
|
|
53
|
+
if (typeof value !== "string") return undefined;
|
|
54
|
+
const trimmed = value.trim();
|
|
55
|
+
return trimmed ? trimmed.slice(0, maxChars) : undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normalizePathList(value: unknown): string[] {
|
|
59
|
+
if (value === null || value === undefined) return [];
|
|
60
|
+
if (!Array.isArray(value)) {
|
|
61
|
+
throw new ImageGenerationError(
|
|
62
|
+
"invalid-parameters",
|
|
63
|
+
"referenceImagePaths must be an array of path strings or null.",
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const normalized: string[] = [];
|
|
68
|
+
for (const rawPath of value) {
|
|
69
|
+
if (typeof rawPath !== "string") {
|
|
70
|
+
throw new ImageGenerationError(
|
|
71
|
+
"invalid-parameters",
|
|
72
|
+
"referenceImagePaths must contain only path strings.",
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
const trimmed = rawPath.trim();
|
|
76
|
+
if (!trimmed) continue;
|
|
77
|
+
if (trimmed.length > MAX_IMAGE_PATH_CHARS) {
|
|
78
|
+
throw new ImageGenerationError(
|
|
79
|
+
"invalid-parameters",
|
|
80
|
+
`Each reference image path must be at most ${MAX_IMAGE_PATH_CHARS} characters.`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
normalized.push(trimmed);
|
|
84
|
+
if (normalized.length > MAX_REFERENCE_IMAGE_COUNT) {
|
|
85
|
+
throw new ImageGenerationError(
|
|
86
|
+
"invalid-parameters",
|
|
87
|
+
`referenceImagePaths must contain at most ${MAX_REFERENCE_IMAGE_COUNT} paths.`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return normalized;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function normalizeOutputPath(value: unknown): string | undefined {
|
|
95
|
+
if (value === null || value === undefined) return undefined;
|
|
96
|
+
if (typeof value !== "string") {
|
|
97
|
+
throw new ImageGenerationError(
|
|
98
|
+
"invalid-parameters",
|
|
99
|
+
"outputPath must be a path string or null.",
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
const trimmed = value.trim();
|
|
103
|
+
if (!trimmed) return undefined;
|
|
104
|
+
if (trimmed.length > MAX_IMAGE_PATH_CHARS) {
|
|
105
|
+
throw new ImageGenerationError(
|
|
106
|
+
"invalid-parameters",
|
|
107
|
+
`outputPath must be at most ${MAX_IMAGE_PATH_CHARS} characters when provided.`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return trimmed;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function normalizeRequestedModel(value: unknown): string | undefined {
|
|
114
|
+
if (value === null || value === undefined) return undefined;
|
|
115
|
+
if (typeof value !== "string") {
|
|
116
|
+
throw new ImageGenerationError("invalid-parameters", "model must be a model id string or null.");
|
|
117
|
+
}
|
|
118
|
+
const trimmed = value.trim();
|
|
119
|
+
if (!trimmed) return undefined;
|
|
120
|
+
if (trimmed.length > MAX_IMAGE_MODEL_ID_CHARS) {
|
|
121
|
+
throw new ImageGenerationError(
|
|
122
|
+
"invalid-parameters",
|
|
123
|
+
`model must be at most ${MAX_IMAGE_MODEL_ID_CHARS} characters when provided.`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
return trimmed;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function normalizeBatchSize(value: unknown): number {
|
|
130
|
+
if (value === null || value === undefined) return 1;
|
|
131
|
+
if (
|
|
132
|
+
typeof value !== "number" ||
|
|
133
|
+
!Number.isInteger(value) ||
|
|
134
|
+
value < 1 ||
|
|
135
|
+
value > MAX_IMAGE_BATCH_SIZE
|
|
136
|
+
) {
|
|
137
|
+
throw new ImageGenerationError(
|
|
138
|
+
"invalid-parameters",
|
|
139
|
+
`batchSize must be an integer from 1 to ${MAX_IMAGE_BATCH_SIZE}.`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return value;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Configured model ids are only trusted as far as the shared bounds: unusable entries are
|
|
147
|
+
* dropped, and an empty or malformed list falls back to the shipped default model.
|
|
148
|
+
*/
|
|
149
|
+
function usableImageModels(models: readonly string[] | undefined): string[] {
|
|
150
|
+
const usable: string[] = [];
|
|
151
|
+
for (const rawModel of Array.isArray(models) ? models : []) {
|
|
152
|
+
if (typeof rawModel !== "string") continue;
|
|
153
|
+
const trimmed = rawModel.trim();
|
|
154
|
+
if (!trimmed || trimmed.length > MAX_IMAGE_MODEL_ID_CHARS) continue;
|
|
155
|
+
if (!usable.includes(trimmed)) usable.push(trimmed);
|
|
156
|
+
}
|
|
157
|
+
return usable.length > 0 ? usable : [...DEFAULT_IMAGE_GENERATION_MODELS];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Resolve the independent Images API model for one call. An omitted request selects the
|
|
162
|
+
* explicit default when supplied (v2), or the first configured model for legacy callers.
|
|
163
|
+
* An explicit request must match a configured id exactly before any paid dispatch.
|
|
164
|
+
*/
|
|
165
|
+
function formatAvailableImageModels(models: readonly string[]): string {
|
|
166
|
+
const visible = models.slice(0, MAX_DISPLAYED_IMAGE_MODELS_IN_ERROR);
|
|
167
|
+
const remaining = models.length - visible.length;
|
|
168
|
+
return remaining > 0 ? `${visible.join(", ")}, … and ${remaining} more` : visible.join(", ");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function selectImageGenerationModel(args: {
|
|
172
|
+
requestedModel?: string;
|
|
173
|
+
configuredModels?: readonly string[];
|
|
174
|
+
defaultModel?: string;
|
|
175
|
+
}): string {
|
|
176
|
+
const models = usableImageModels(args.configuredModels);
|
|
177
|
+
if (args.defaultModel !== undefined && (!args.configuredModels?.length ||
|
|
178
|
+
!args.configuredModels.every((model) => typeof model === "string" && model.trim().length > 0 && model.trim().length <= MAX_IMAGE_MODEL_ID_CHARS) ||
|
|
179
|
+
!models.includes(args.defaultModel))) {
|
|
180
|
+
throw new ImageGenerationError("invalid-parameters", "The configured default image model must be in the nonempty allowedModels list.");
|
|
181
|
+
}
|
|
182
|
+
const requested = typeof args.requestedModel === "string" ? args.requestedModel.trim() : "";
|
|
183
|
+
if (!requested) return args.defaultModel ?? models[0]!;
|
|
184
|
+
if (models.includes(requested)) return requested;
|
|
185
|
+
throw new ImageGenerationError(
|
|
186
|
+
"invalid-parameters",
|
|
187
|
+
`Unknown image generation model "${requested.slice(0, MAX_IMAGE_MODEL_ID_CHARS)}". ` +
|
|
188
|
+
`Configure it in imageGeneration.allowedModels (legacy: imageGeneration.models) first; available models: ${formatAvailableImageModels(models)}.`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function normalizeGenerateImageParams(
|
|
193
|
+
params: GenerateImageParams,
|
|
194
|
+
): NormalizedGenerateImageParams {
|
|
195
|
+
const prompt = typeof params.prompt === "string" ? params.prompt.trim() : "";
|
|
196
|
+
if (!prompt || prompt.length > MAX_IMAGE_PROMPT_CHARS) {
|
|
197
|
+
throw new ImageGenerationError(
|
|
198
|
+
"invalid-parameters",
|
|
199
|
+
`prompt must be 1-${MAX_IMAGE_PROMPT_CHARS} characters.`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const size = params.size ?? "auto";
|
|
204
|
+
if (!(IMAGE_GENERATION_SIZES as readonly string[]).includes(size)) {
|
|
205
|
+
throw new ImageGenerationError(
|
|
206
|
+
"invalid-parameters",
|
|
207
|
+
`Unsupported image size: ${String(size)}.`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const quality = params.quality ?? "auto";
|
|
212
|
+
if (!(IMAGE_GENERATION_QUALITIES as readonly string[]).includes(quality)) {
|
|
213
|
+
throw new ImageGenerationError(
|
|
214
|
+
"invalid-parameters",
|
|
215
|
+
`Unsupported image quality: ${String(quality)}.`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const referenceImagePaths = normalizePathList(params.referenceImagePaths);
|
|
220
|
+
const outputPath = normalizeOutputPath(params.outputPath);
|
|
221
|
+
const batchSize = normalizeBatchSize(params.batchSize);
|
|
222
|
+
if (batchSize > 1 && outputPath !== undefined) {
|
|
223
|
+
throw new ImageGenerationError(
|
|
224
|
+
"invalid-parameters",
|
|
225
|
+
"outputPath cannot be used when batchSize is greater than 1; use the generated artifacts instead.",
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
prompt,
|
|
231
|
+
referenceImagePaths,
|
|
232
|
+
outputPath,
|
|
233
|
+
size,
|
|
234
|
+
quality,
|
|
235
|
+
model: normalizeRequestedModel(params.model),
|
|
236
|
+
batchSize,
|
|
237
|
+
action: referenceImagePaths.length > 0 ? "edit" : "generate",
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function buildImageGenerationRequest(args: {
|
|
242
|
+
imageModel: string;
|
|
243
|
+
params: NormalizedGenerateImageParams;
|
|
244
|
+
references: readonly PreparedReferenceImage[];
|
|
245
|
+
}): ImageGenerationRequestBody {
|
|
246
|
+
const imageModel = typeof args.imageModel === "string" ? args.imageModel.trim() : "";
|
|
247
|
+
if (!imageModel || imageModel.length > MAX_IMAGE_MODEL_ID_CHARS) {
|
|
248
|
+
throw new ImageGenerationError(
|
|
249
|
+
"invalid-parameters",
|
|
250
|
+
"A configured image generation model is required.",
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
action: args.params.action,
|
|
255
|
+
model: imageModel,
|
|
256
|
+
prompt: args.params.prompt,
|
|
257
|
+
n: 1,
|
|
258
|
+
size: args.params.size,
|
|
259
|
+
quality: args.params.quality,
|
|
260
|
+
references: args.references,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function hasPngEndChunk(buffer: Buffer): boolean {
|
|
265
|
+
return (
|
|
266
|
+
buffer.length >= 33 &&
|
|
267
|
+
buffer.readUInt32BE(buffer.length - 12) === 0 &&
|
|
268
|
+
buffer.toString("ascii", buffer.length - 8, buffer.length - 4) === "IEND"
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function detectReferenceImageMimeType(bytes: Uint8Array): ReferenceImageMimeType | undefined {
|
|
273
|
+
return readImageMetadata(bytes)?.mimeType;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function isValidPng(bytes: Uint8Array): boolean {
|
|
277
|
+
return bytes.byteLength >= PNG_SIGNATURE.length && Buffer.from(bytes).subarray(0, 8).equals(PNG_SIGNATURE);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function readPngDimensions(bytes: Uint8Array): { width: number; height: number } | undefined {
|
|
281
|
+
const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
282
|
+
if (!isValidPng(buffer) || buffer.length < 24) return undefined;
|
|
283
|
+
if (buffer.readUInt32BE(8) !== 13 || buffer.toString("ascii", 12, 16) !== "IHDR") return undefined;
|
|
284
|
+
const width = buffer.readUInt32BE(16);
|
|
285
|
+
const height = buffer.readUInt32BE(20);
|
|
286
|
+
if (
|
|
287
|
+
width <= 0 ||
|
|
288
|
+
height <= 0 ||
|
|
289
|
+
width > MAX_IMAGE_DIMENSION ||
|
|
290
|
+
height > MAX_IMAGE_DIMENSION
|
|
291
|
+
) return undefined;
|
|
292
|
+
return { width, height };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function boundedDimensions(width: number, height: number): { width: number; height: number } | undefined {
|
|
296
|
+
if (
|
|
297
|
+
!Number.isInteger(width) ||
|
|
298
|
+
!Number.isInteger(height) ||
|
|
299
|
+
width <= 0 ||
|
|
300
|
+
height <= 0 ||
|
|
301
|
+
width > MAX_IMAGE_DIMENSION ||
|
|
302
|
+
height > MAX_IMAGE_DIMENSION
|
|
303
|
+
) {
|
|
304
|
+
return undefined;
|
|
305
|
+
}
|
|
306
|
+
return { width, height };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const JPEG_DIMENSION_MARKERS = new Set([
|
|
310
|
+
0xc0,
|
|
311
|
+
0xc1,
|
|
312
|
+
0xc2,
|
|
313
|
+
0xc3,
|
|
314
|
+
0xc5,
|
|
315
|
+
0xc6,
|
|
316
|
+
0xc7,
|
|
317
|
+
0xc9,
|
|
318
|
+
0xca,
|
|
319
|
+
0xcb,
|
|
320
|
+
0xcd,
|
|
321
|
+
0xce,
|
|
322
|
+
0xcf,
|
|
323
|
+
]);
|
|
324
|
+
|
|
325
|
+
function readJpegDimensions(bytes: Uint8Array): { width: number; height: number } | undefined {
|
|
326
|
+
const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
327
|
+
if (buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) return undefined;
|
|
328
|
+
let offset = 2;
|
|
329
|
+
while (offset < buffer.length) {
|
|
330
|
+
if (buffer[offset] !== 0xff) {
|
|
331
|
+
offset += 1;
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
while (offset < buffer.length && buffer[offset] === 0xff) offset += 1;
|
|
335
|
+
if (offset >= buffer.length) return undefined;
|
|
336
|
+
const marker = buffer[offset++];
|
|
337
|
+
if (marker === 0xd9 || marker === 0xda) return undefined;
|
|
338
|
+
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue;
|
|
339
|
+
if (offset + 2 > buffer.length) return undefined;
|
|
340
|
+
const segmentLength = buffer.readUInt16BE(offset);
|
|
341
|
+
if (segmentLength < 2 || offset + segmentLength > buffer.length) return undefined;
|
|
342
|
+
if (JPEG_DIMENSION_MARKERS.has(marker) && segmentLength >= 7) {
|
|
343
|
+
const height = buffer.readUInt16BE(offset + 3);
|
|
344
|
+
const width = buffer.readUInt16BE(offset + 5);
|
|
345
|
+
return boundedDimensions(width, height);
|
|
346
|
+
}
|
|
347
|
+
offset += segmentLength;
|
|
348
|
+
}
|
|
349
|
+
return undefined;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function hasJpegEndMarker(bytes: Uint8Array): boolean {
|
|
353
|
+
const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
354
|
+
return buffer.length >= 2 && buffer[buffer.length - 2] === 0xff && buffer[buffer.length - 1] === 0xd9;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function readWebpDimensions(bytes: Uint8Array): { width: number; height: number } | undefined {
|
|
358
|
+
const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
359
|
+
if (
|
|
360
|
+
buffer.length < 20 ||
|
|
361
|
+
buffer.toString("ascii", 0, 4) !== "RIFF" ||
|
|
362
|
+
buffer.toString("ascii", 8, 12) !== "WEBP"
|
|
363
|
+
) {
|
|
364
|
+
return undefined;
|
|
365
|
+
}
|
|
366
|
+
const declaredSize = buffer.readUInt32LE(4) + 8;
|
|
367
|
+
if (declaredSize !== buffer.length) return undefined;
|
|
368
|
+
let offset = 12;
|
|
369
|
+
while (offset + 8 <= buffer.length) {
|
|
370
|
+
const chunkType = buffer.toString("ascii", offset, offset + 4);
|
|
371
|
+
const chunkSize = buffer.readUInt32LE(offset + 4);
|
|
372
|
+
const dataStart = offset + 8;
|
|
373
|
+
const dataEnd = dataStart + chunkSize;
|
|
374
|
+
if (dataEnd > buffer.length) return undefined;
|
|
375
|
+
if (chunkType === "VP8X" && chunkSize >= 10) {
|
|
376
|
+
const width = 1 + buffer.readUIntLE(dataStart + 4, 3);
|
|
377
|
+
const height = 1 + buffer.readUIntLE(dataStart + 7, 3);
|
|
378
|
+
return boundedDimensions(width, height);
|
|
379
|
+
}
|
|
380
|
+
if (chunkType === "VP8 " && chunkSize >= 10) {
|
|
381
|
+
if (
|
|
382
|
+
buffer[dataStart + 3] === 0x9d &&
|
|
383
|
+
buffer[dataStart + 4] === 0x01 &&
|
|
384
|
+
buffer[dataStart + 5] === 0x2a
|
|
385
|
+
) {
|
|
386
|
+
const width = buffer.readUInt16LE(dataStart + 6) & 0x3fff;
|
|
387
|
+
const height = buffer.readUInt16LE(dataStart + 8) & 0x3fff;
|
|
388
|
+
return boundedDimensions(width, height);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
if (chunkType === "VP8L" && chunkSize >= 5 && buffer[dataStart] === 0x2f) {
|
|
392
|
+
const width = 1 + ((buffer[dataStart + 1] | (buffer[dataStart + 2] << 8)) & 0x3fff);
|
|
393
|
+
const height = 1 + ((buffer[dataStart + 2] >> 6) | (buffer[dataStart + 3] << 2) | (buffer[dataStart + 4] << 10));
|
|
394
|
+
return boundedDimensions(width, height);
|
|
395
|
+
}
|
|
396
|
+
offset = dataEnd + (chunkSize % 2);
|
|
397
|
+
}
|
|
398
|
+
return undefined;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function extractBase64(value: string): string {
|
|
402
|
+
const trimmed = value.trim();
|
|
403
|
+
const dataUrl = DATA_URL_PATTERN.exec(trimmed);
|
|
404
|
+
return (dataUrl?.[1] ?? trimmed).trim();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function normalizeImageMimeType(value: unknown): ImageGenerationMimeType | undefined {
|
|
408
|
+
if (typeof value !== "string") return undefined;
|
|
409
|
+
const normalized = value.trim().toLowerCase();
|
|
410
|
+
if (normalized === "image/jpg") return "image/jpeg";
|
|
411
|
+
return (IMAGE_GENERATION_MIME_TYPES as readonly string[]).includes(normalized)
|
|
412
|
+
? normalized as ImageGenerationMimeType
|
|
413
|
+
: undefined;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export function readImageMetadata(bytes: Uint8Array):
|
|
417
|
+
| { mimeType: ImageGenerationMimeType; width: number; height: number }
|
|
418
|
+
| undefined {
|
|
419
|
+
if (readPngDimensions(bytes) && hasPngEndChunk(Buffer.from(bytes))) {
|
|
420
|
+
const dimensions = readPngDimensions(bytes)!;
|
|
421
|
+
return { mimeType: "image/png", ...dimensions };
|
|
422
|
+
}
|
|
423
|
+
if (hasJpegEndMarker(bytes)) {
|
|
424
|
+
const dimensions = readJpegDimensions(bytes);
|
|
425
|
+
if (dimensions) return { mimeType: "image/jpeg", ...dimensions };
|
|
426
|
+
}
|
|
427
|
+
const dimensions = readWebpDimensions(bytes);
|
|
428
|
+
if (dimensions) return { mimeType: "image/webp", ...dimensions };
|
|
429
|
+
return undefined;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export function decodeGeneratedImage(value: string, hintedMimeType?: unknown):
|
|
433
|
+
| { ok: true; bytes: Buffer; mimeType: ImageGenerationMimeType; width: number; height: number }
|
|
434
|
+
| { ok: false; reason: "malformed-response" | "oversized-response"; errorMessage: string } {
|
|
435
|
+
const base64 = extractBase64(value);
|
|
436
|
+
const maxBase64Chars = Math.ceil(MAX_GENERATED_IMAGE_BYTES / 3) * 4;
|
|
437
|
+
if (base64.length > maxBase64Chars) {
|
|
438
|
+
return {
|
|
439
|
+
ok: false,
|
|
440
|
+
reason: "oversized-response",
|
|
441
|
+
errorMessage: "Image generation returned an image larger than the 32 MiB limit.",
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
if (!base64 || base64.length % 4 !== 0 || !BASE64_PATTERN.test(base64)) {
|
|
445
|
+
return {
|
|
446
|
+
ok: false,
|
|
447
|
+
reason: "malformed-response",
|
|
448
|
+
errorMessage: "Image generation returned invalid base64 image data.",
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const bytes = Buffer.from(base64, "base64");
|
|
453
|
+
if (
|
|
454
|
+
bytes.length === 0 ||
|
|
455
|
+
bytes.length > MAX_GENERATED_IMAGE_BYTES ||
|
|
456
|
+
bytes.toString("base64") !== base64
|
|
457
|
+
) {
|
|
458
|
+
bytes.fill(0);
|
|
459
|
+
return {
|
|
460
|
+
ok: false,
|
|
461
|
+
reason: bytes.length > MAX_GENERATED_IMAGE_BYTES ? "oversized-response" : "malformed-response",
|
|
462
|
+
errorMessage: "Image generation returned invalid or oversized image data.",
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
const metadata = readImageMetadata(bytes);
|
|
466
|
+
if (!metadata) {
|
|
467
|
+
bytes.fill(0);
|
|
468
|
+
return {
|
|
469
|
+
ok: false,
|
|
470
|
+
reason: "malformed-response",
|
|
471
|
+
errorMessage: "Image generation returned data that is not a supported PNG, JPEG, or WebP image.",
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
if (typeof hintedMimeType === "string" && hintedMimeType.trim()) {
|
|
475
|
+
const hinted = normalizeImageMimeType(hintedMimeType);
|
|
476
|
+
if (!hinted || hinted !== metadata.mimeType) {
|
|
477
|
+
bytes.fill(0);
|
|
478
|
+
return {
|
|
479
|
+
ok: false,
|
|
480
|
+
reason: "malformed-response",
|
|
481
|
+
errorMessage: "Image generation returned an image with inconsistent MIME metadata.",
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return { ok: true, bytes, ...metadata };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
export function decodeGeneratedPng(value: string):
|
|
489
|
+
| { ok: true; bytes: Buffer; width: number; height: number }
|
|
490
|
+
| { ok: false; reason: "malformed-response" | "oversized-response"; errorMessage: string } {
|
|
491
|
+
const decoded = decodeGeneratedImage(value, "image/png");
|
|
492
|
+
if (!decoded.ok) return decoded;
|
|
493
|
+
return {
|
|
494
|
+
ok: true,
|
|
495
|
+
bytes: decoded.bytes,
|
|
496
|
+
width: decoded.width,
|
|
497
|
+
height: decoded.height,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export function extractProviderErrorMessage(value: unknown, fallback: string): string {
|
|
502
|
+
if (!isRecord(value)) return fallback;
|
|
503
|
+
const direct = normalizedOptionalString(value.message, MAX_IMAGE_DIAGNOSTIC_CHARS);
|
|
504
|
+
if (direct) return sanitizeImageDiagnostic(direct, fallback);
|
|
505
|
+
if (isRecord(value.error)) {
|
|
506
|
+
const nested = normalizedOptionalString(value.error.message, MAX_IMAGE_DIAGNOSTIC_CHARS);
|
|
507
|
+
if (nested) return sanitizeImageDiagnostic(nested, fallback);
|
|
508
|
+
}
|
|
509
|
+
return fallback;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
export function parseImagesApiResponse(value: unknown): ParsedImageResponseResult {
|
|
513
|
+
if (!isRecord(value)) {
|
|
514
|
+
return {
|
|
515
|
+
ok: false,
|
|
516
|
+
reason: "malformed-response",
|
|
517
|
+
errorMessage: "Image generation returned a non-object response.",
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
if (!Array.isArray(value.data)) {
|
|
521
|
+
return {
|
|
522
|
+
ok: false,
|
|
523
|
+
reason: "malformed-response",
|
|
524
|
+
errorMessage: "Image generation response did not contain a data array.",
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
if (value.data.length === 0) {
|
|
528
|
+
return {
|
|
529
|
+
ok: false,
|
|
530
|
+
reason: "no-image",
|
|
531
|
+
errorMessage: "Image generation completed without a usable image result.",
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
if (value.data.length !== 1) {
|
|
535
|
+
return {
|
|
536
|
+
ok: false,
|
|
537
|
+
reason: "malformed-response",
|
|
538
|
+
errorMessage: "Image generation returned more than one image for a single request.",
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const item = value.data[0];
|
|
543
|
+
if (!isRecord(item) || typeof item.b64_json !== "string" || !item.b64_json.trim()) {
|
|
544
|
+
return {
|
|
545
|
+
ok: false,
|
|
546
|
+
reason: "no-image",
|
|
547
|
+
errorMessage: "Image generation response did not contain base64 image data.",
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
const mimeHint = item.mime_type ?? item.mimeType;
|
|
551
|
+
if (mimeHint !== undefined && typeof mimeHint !== "string") {
|
|
552
|
+
return {
|
|
553
|
+
ok: false,
|
|
554
|
+
reason: "malformed-response",
|
|
555
|
+
errorMessage: "Image generation returned invalid MIME metadata.",
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
const decoded = decodeGeneratedImage(item.b64_json, mimeHint);
|
|
559
|
+
if (!decoded.ok) return decoded;
|
|
560
|
+
|
|
561
|
+
return {
|
|
562
|
+
ok: true,
|
|
563
|
+
image: {
|
|
564
|
+
bytes: decoded.bytes,
|
|
565
|
+
mimeType: decoded.mimeType,
|
|
566
|
+
imageCallId:
|
|
567
|
+
normalizedOptionalString(item.id, MAX_IMAGE_IDENTIFIER_CHARS) ?? "image_generation_0",
|
|
568
|
+
responseId: normalizedOptionalString(value.id, MAX_IMAGE_IDENTIFIER_CHARS),
|
|
569
|
+
revisedPrompt: normalizedOptionalString(item.revised_prompt, MAX_IMAGE_DIAGNOSTIC_CHARS),
|
|
570
|
+
width: decoded.width,
|
|
571
|
+
height: decoded.height,
|
|
572
|
+
},
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
export const _protocolTest = {
|
|
577
|
+
PNG_SIGNATURE,
|
|
578
|
+
IMAGE_GENERATION_MIME_TYPES,
|
|
579
|
+
};
|