202 lines
9.6 KiB
JavaScript
202 lines
9.6 KiB
JavaScript
const MAX_BYTES = 64 * 1024 * 1024;
|
|
const MAX_EDIT_BYTES = 20 * 1024 * 1024;
|
|
const TIMEOUT_MS = 180_000;
|
|
const QUALITY = new Set(['auto', 'low', 'medium', 'high', 'standard', 'hd']);
|
|
const BACKGROUND = new Set(['auto', 'transparent', 'opaque']);
|
|
const OUTPUT_FORMAT = new Set(['png', 'jpeg', 'webp']);
|
|
|
|
export function validateBaseUrl(value) {
|
|
if (typeof value !== 'string' || !value.trim()) throw new Error('Base URL is required.');
|
|
let url;
|
|
try { url = new URL(value.trim()); } catch { throw new Error('Base URL must be an absolute HTTP(S) URL.'); }
|
|
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || /[?#]/.test(value)) {
|
|
throw new Error('Base URL must use HTTP(S) without credentials, query, or fragment.');
|
|
}
|
|
return url.href.replace(/\/+$/, '');
|
|
}
|
|
|
|
export function validateGeneration(input) {
|
|
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Generation input must be an object.');
|
|
const result = {};
|
|
for (const [key, maximum] of [['model', 200], ['prompt', 32000]]) {
|
|
if (typeof input[key] !== 'string' || !input[key].trim() || input[key].length > maximum) {
|
|
throw new Error(`${key} must be a nonempty string of at most ${maximum} characters.`);
|
|
}
|
|
result[key] = input[key];
|
|
}
|
|
result.n = input.n === undefined ? 1 : input.n;
|
|
if (!Number.isInteger(result.n) || result.n < 1 || result.n > 4) throw new Error('n must be an integer between 1 and 4.');
|
|
if (input.size !== undefined) {
|
|
if (typeof input.size !== 'string' || !/^(?:auto|[0-9]+x[0-9]+)$/.test(input.size)) throw new Error('size must be auto or WIDTHxHEIGHT.');
|
|
result.size = input.size;
|
|
}
|
|
for (const [key, values] of [['quality', QUALITY], ['background', BACKGROUND], ['output_format', OUTPUT_FORMAT]]) {
|
|
if (input[key] !== undefined) {
|
|
if (!values.has(input[key])) throw new Error(`Invalid ${key}.`);
|
|
result[key] = input[key];
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function apiHeaders(apiKey) {
|
|
if (typeof apiKey !== 'string' || !apiKey.trim() || /[\r\n]/.test(apiKey)) throw new Error('A valid API key is required.');
|
|
return { Authorization: `Bearer ${apiKey}` };
|
|
}
|
|
|
|
async function operation(signal, work) {
|
|
const controller = new AbortController();
|
|
let timedOut = false;
|
|
const abort = () => controller.abort();
|
|
if (signal?.aborted) abort();
|
|
signal?.addEventListener('abort', abort, { once: true });
|
|
const timer = setTimeout(() => { timedOut = true; controller.abort(); }, TIMEOUT_MS);
|
|
timer.unref?.();
|
|
try {
|
|
if (controller.signal.aborted) throw new Error('Request aborted.');
|
|
return await work(controller.signal);
|
|
} catch (error) {
|
|
if (controller.signal.aborted) throw new Error(timedOut ? 'Request timed out after 180 seconds.' : 'Request aborted.');
|
|
throw error;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
signal?.removeEventListener('abort', abort);
|
|
}
|
|
}
|
|
|
|
async function fetchBytes(url, options, label, budget) {
|
|
let response;
|
|
try { response = await fetch(url, { ...options, redirect: 'error' }); }
|
|
catch { throw new Error(`${label} network request failed.`); }
|
|
if (!response.ok) {
|
|
try { await response.body?.cancel(); } catch { /* Do not expose provider errors. */ }
|
|
throw new Error(`${label} failed with HTTP ${response.status}.`);
|
|
}
|
|
const declared = response.headers.get('content-length');
|
|
if (declared && Number(declared) > budget.remaining) {
|
|
try { await response.body?.cancel(); } catch { /* Best effort cancellation. */ }
|
|
throw new Error(`${label} exceeds the 64 MB response limit.`);
|
|
}
|
|
if (!response.body) throw new Error(`${label} returned an empty response.`);
|
|
const reader = response.body.getReader();
|
|
const chunks = [];
|
|
let length = 0;
|
|
try {
|
|
while (true) {
|
|
let part;
|
|
try { part = await reader.read(); } catch { throw new Error(`${label} response download failed.`); }
|
|
if (part.done) break;
|
|
length += part.value.byteLength;
|
|
budget.remaining -= part.value.byteLength;
|
|
if (budget.remaining < 0) {
|
|
try { await reader.cancel(); } catch { /* Best effort cancellation. */ }
|
|
throw new Error(`${label} exceeds the 64 MB response limit.`);
|
|
}
|
|
chunks.push(Buffer.from(part.value));
|
|
}
|
|
} finally { reader.releaseLock(); }
|
|
return Buffer.concat(chunks, length);
|
|
}
|
|
|
|
async function fetchJson(url, options, budget) {
|
|
const bytes = await fetchBytes(url, options, 'API request', budget);
|
|
try { return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); }
|
|
catch { throw new Error('API returned malformed JSON.'); }
|
|
}
|
|
|
|
function decodeBase64(value) {
|
|
if (typeof value !== 'string' || !value || value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) {
|
|
throw new Error('API returned invalid base64 image data.');
|
|
}
|
|
const bytes = Buffer.from(value, 'base64');
|
|
if (bytes.toString('base64') !== value) throw new Error('API returned invalid base64 image data.');
|
|
return bytes;
|
|
}
|
|
|
|
function mediaType(bytes) {
|
|
if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return 'image/png';
|
|
if (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) return 'image/jpeg';
|
|
if (bytes.length >= 6 && ['GIF87a', 'GIF89a'].includes(bytes.toString('latin1', 0, 6))) return 'image/gif';
|
|
if (bytes.length >= 12 && bytes.toString('latin1', 0, 4) === 'RIFF' && bytes.toString('latin1', 8, 12) === 'WEBP') return 'image/webp';
|
|
throw new Error('Image bytes are not a supported PNG, JPEG, WebP, or GIF image.');
|
|
}
|
|
|
|
/** Validate one PNG/JPEG/WebP upload by signature, not full image decoding.
|
|
* Accepts Buffer/Uint8Array up to 20 MiB; returns an owned Buffer copy and MIME type.
|
|
*/
|
|
export function validateEditImage(data) {
|
|
if (!(data instanceof Uint8Array) || data.byteLength === 0) throw new Error('Edit image must be nonempty PNG, JPEG, or WebP bytes.');
|
|
if (data.byteLength > MAX_EDIT_BYTES) throw new Error('Edit image exceeds the 20 MiB upload limit.');
|
|
const bytes = Buffer.from(data);
|
|
let type;
|
|
try { type = mediaType(bytes); } catch { /* Use the upload-specific error below. */ }
|
|
if (!['image/png', 'image/jpeg', 'image/webp'].includes(type)) throw new Error('Edit image must be a PNG, JPEG, or WebP image.');
|
|
return { data: bytes, mediaType: type };
|
|
}
|
|
|
|
function imageUrl(value) {
|
|
let url;
|
|
try { url = new URL(value); } catch { throw new Error('API returned an invalid image URL.'); }
|
|
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error('API returned an invalid image URL.');
|
|
return url.href;
|
|
}
|
|
|
|
export async function listModels({ baseUrl, apiKey, signal }) {
|
|
const endpoint = `${validateBaseUrl(baseUrl)}/models`;
|
|
const headers = apiHeaders(apiKey);
|
|
return operation(signal, async combinedSignal => {
|
|
const json = await fetchJson(endpoint, { headers, signal: combinedSignal }, { remaining: MAX_BYTES });
|
|
if (!json || !Array.isArray(json.data) || json.data.some(item => !item || typeof item.id !== 'string' || !item.id.trim())) {
|
|
throw new Error('API returned a malformed model list.');
|
|
}
|
|
return json.data.map(item => item.id);
|
|
});
|
|
}
|
|
|
|
export async function generateImages({ baseUrl, apiKey, input, signal }) {
|
|
const endpoint = `${validateBaseUrl(baseUrl)}/images/generations`;
|
|
const generation = validateGeneration(input);
|
|
const headers = { ...apiHeaders(apiKey), 'Content-Type': 'application/json' };
|
|
const body = JSON.stringify(generation);
|
|
if (Buffer.byteLength(body) > MAX_BYTES) throw new Error('Request exceeds the 64 MB request limit.');
|
|
return requestImages({ endpoint, headers, body, generation, signal });
|
|
}
|
|
|
|
/** Edit one image; input and return value share the generateImages contract. */
|
|
export async function editImages({ baseUrl, apiKey, input, image, signal }) {
|
|
const endpoint = `${validateBaseUrl(baseUrl)}/images/edits`;
|
|
const generation = validateGeneration(input);
|
|
const headers = apiHeaders(apiKey);
|
|
const upload = validateEditImage(image);
|
|
const extension = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp' }[upload.mediaType];
|
|
const body = new FormData();
|
|
for (const [key, value] of Object.entries(generation)) body.set(key, String(value));
|
|
body.set('image', new Blob([upload.data], { type: upload.mediaType }), `image.${extension}`);
|
|
return requestImages({ endpoint, headers, body, generation, signal });
|
|
}
|
|
|
|
function requestImages({ endpoint, headers, body, generation, signal }) {
|
|
return operation(signal, async combinedSignal => {
|
|
const budget = { remaining: MAX_BYTES };
|
|
const json = await fetchJson(endpoint, { method: 'POST', headers, body, signal: combinedSignal }, budget);
|
|
if (!json || !Array.isArray(json.data) || !json.data.length) throw new Error('API returned a malformed image response.');
|
|
const images = [];
|
|
let decodedBytes = 0;
|
|
for (const item of json.data) {
|
|
if (!item || typeof item !== 'object' || (item.revised_prompt !== undefined && typeof item.revised_prompt !== 'string')) {
|
|
throw new Error('API returned a malformed image entry.');
|
|
}
|
|
let bytes;
|
|
if (item.b64_json !== undefined) bytes = decodeBase64(item.b64_json);
|
|
else if (typeof item.url === 'string') bytes = await fetchBytes(imageUrl(item.url), { signal: combinedSignal }, 'Image download', budget);
|
|
else throw new Error('API returned an image without base64 data or a URL.');
|
|
decodedBytes += bytes.length;
|
|
if (decodedBytes > MAX_BYTES) throw new Error('Images exceed the 64 MB response limit.');
|
|
const image = { data: bytes, mediaType: mediaType(bytes) };
|
|
if (item.revised_prompt !== undefined) image.revisedPrompt = item.revised_prompt;
|
|
images.push(image);
|
|
}
|
|
return { model: generation.model, prompt: generation.prompt, images };
|
|
});
|
|
}
|