UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

464 lines (463 loc) 17.4 kB
import { a as normalizeLowercaseStringOrEmpty, c as normalizeOptionalString } from "./string-coerce-mnp54Vah.js"; import { c as isRecord } from "./utils-CCC-BEJH.js"; import { n as readResponseWithLimit } from "./read-response-with-limit-MDCSJrlg.js"; import { m as mergeSsrFPolicies } from "./ssrf-CvPEXMGn.js"; import { r as fetchWithSsrFGuard } from "./fetch-guard-BttkNCLm.js"; import { i as assertOkOrThrowProviderError, r as assertOkOrThrowHttpError } from "./provider-http-errors-DqaqQLLZ.js"; import "./string-coerce-runtime-CEGJWkQ_.js"; import { r as isProviderApiKeyConfigured } from "./provider-auth-DAOC_qI9.js"; import { n as buildHostnameAllowlistPolicyFromSuffixAllowlist, u as ssrfPolicyFromDangerouslyAllowPrivateNetwork } from "./ssrf-policy-sZLyyYht.js"; import "./ssrf-runtime-BOGN5pUi.js"; import "./response-limit-runtime-B0ZHF0eR.js"; import "./provider-http-BXCBCi4E.js"; import { a as imageFileExtensionForMimeType, u as toImageDataUrl } from "./image-generation-C56HfGFi.js"; import { t as resolveFalHttpRequestConfig } from "./http-config-B_zQL2V4.js"; //#region extensions/fal/image-generation-provider.ts const DEFAULT_FAL_IMAGE_MODEL = "fal-ai/flux/dev"; const DEFAULT_FAL_EDIT_SUBPATH = "image-to-image"; const FAL_KREA_2_MODEL_PREFIX = "krea/v2/"; const FAL_KREA_2_MEDIUM_MODEL = "krea/v2/medium/text-to-image"; const FAL_KREA_2_LARGE_MODEL = "krea/v2/large/text-to-image"; const DEFAULT_OUTPUT_FORMAT = "png"; const GPT_IMAGE_EDIT_MAX_INPUT_IMAGES = 10; const NANO_BANANA_EDIT_MAX_INPUT_IMAGES = 14; const KREA_STYLE_REFERENCE_MAX_INPUT_IMAGES = 10; const FAL_OUTPUT_FORMATS = ["png", "jpeg"]; const FAL_SUPPORTED_SIZES = [ "1024x1024", "1024x1536", "1536x1024", "1024x1792", "1792x1024" ]; const FAL_SUPPORTED_ASPECT_RATIOS = [ "1:1", "2:3", "3:2", "2.35:1", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9", "4:1", "1:4", "8:1", "1:8" ]; const KREA_SUPPORTED_ASPECT_RATIOS = [ "1:1", "4:3", "3:2", "16:9", "2.35:1", "4:5", "2:3", "9:16" ]; const NANO_BANANA_SUPPORTED_ASPECT_RATIOS = [ "21:9", "16:9", "3:2", "4:3", "5:4", "1:1", "4:5", "3:4", "2:3", "9:16", "4:1", "1:4", "8:1", "1:8" ]; const KREA_CREATIVITY_LEVELS = [ "raw", "low", "medium", "high" ]; const FAL_IMAGE_MALFORMED_RESPONSE = "fal image generation response malformed"; const DEFAULT_GENERATED_IMAGE_MAX_BYTES = 6 * 1024 * 1024; let falFetchGuard = fetchWithSsrFGuard; function setFalFetchGuardForTesting(impl) { falFetchGuard = impl ?? fetchWithSsrFGuard; } function matchesTrustedHostSuffix(hostname, trustedSuffix) { const normalizedHost = normalizeLowercaseStringOrEmpty(hostname); const normalizedSuffix = normalizeLowercaseStringOrEmpty(trustedSuffix); return normalizedHost === normalizedSuffix || normalizedHost.endsWith(`.${normalizedSuffix}`); } function parseFalImageGenerationResponse(payload) { if (!isRecord(payload)) throw new Error(FAL_IMAGE_MALFORMED_RESPONSE); const rawImages = payload.images; if (rawImages === void 0 || rawImages === null) return { images: [], prompt: normalizeOptionalString(payload.prompt) }; if (!Array.isArray(rawImages)) throw new Error(FAL_IMAGE_MALFORMED_RESPONSE); const images = []; for (const entry of rawImages) { if (!isRecord(entry)) throw new Error(FAL_IMAGE_MALFORMED_RESPONSE); images.push(entry); } return { images, prompt: normalizeOptionalString(payload.prompt) }; } function resolveFalNetworkPolicy(params) { let parsedBaseUrl; try { parsedBaseUrl = new URL(params.baseUrl); } catch { return {}; } const hostSuffix = normalizeLowercaseStringOrEmpty(parsedBaseUrl.hostname); if (!hostSuffix || !params.allowPrivateNetwork) return {}; const trustedHostPolicy = mergeSsrFPolicies(buildHostnameAllowlistPolicyFromSuffixAllowlist([hostSuffix]), ssrfPolicyFromDangerouslyAllowPrivateNetwork(true)); return { apiPolicy: trustedHostPolicy, trustedDownloadHostSuffix: hostSuffix, trustedDownloadPolicy: trustedHostPolicy }; } function ensureFalModelPath(model, hasInputImages) { const trimmed = model?.trim() || DEFAULT_FAL_IMAGE_MODEL; const schema = resolveFalImageModelSchema(trimmed); if (hasInputImages && schema.appendEditPath === false) return trimmed; if (!hasInputImages) return trimmed; if (trimmed.endsWith("/edit") || trimmed.endsWith(`/${DEFAULT_FAL_EDIT_SUBPATH}`) || trimmed.includes("/image-to-image/")) return trimmed; if (trimmed.startsWith("openai/gpt-image-") || trimmed.startsWith("fal-ai/nano-banana-")) return `${trimmed}/edit`; return `${trimmed}/${DEFAULT_FAL_EDIT_SUBPATH}`; } function resolveFalImageModelSchema(model) { if (model.startsWith(FAL_KREA_2_MODEL_PREFIX)) return { geometry: "native_aspect_ratio", aspectRatios: KREA_SUPPORTED_ASPECT_RATIOS, referenceImages: "image_style_references", maxInputImages: KREA_STYLE_REFERENCE_MAX_INPUT_IMAGES, referenceLimitLabel: "fal Krea 2", referenceLimitNoun: "style reference", appendEditPath: false, supportsCount: false, supportsOutputFormat: false, defaultBody: { creativity: "medium" } }; if (model.startsWith("openai/gpt-image-") || model.startsWith("fal-ai/nano-banana-")) { const isNanoBanana = model.startsWith("fal-ai/nano-banana-"); return { geometry: isNanoBanana ? "native_aspect_ratio" : "image_size", ...isNanoBanana ? { aspectRatios: NANO_BANANA_SUPPORTED_ASPECT_RATIOS } : {}, referenceImages: "image_urls", maxInputImages: isNanoBanana ? NANO_BANANA_EDIT_MAX_INPUT_IMAGES : GPT_IMAGE_EDIT_MAX_INPUT_IMAGES, referenceLimitLabel: isNanoBanana ? "fal Nano Banana 2" : "fal GPT Image edit", referenceLimitNoun: "reference image", appendEditPath: "edit", supportsCount: true, supportsOutputFormat: true }; } return { geometry: "image_size", referenceImages: "image_url", maxInputImages: 1, referenceLimitLabel: "fal flux image generation currently", referenceLimitNoun: "reference image", appendEditPath: "image-to-image", supportsCount: true, supportsOutputFormat: true }; } function parseSize(raw) { const trimmed = raw?.trim(); if (!trimmed) return null; const match = /^(\d{2,5})x(\d{2,5})$/iu.exec(trimmed); if (!match) return null; const width = Number.parseInt(match[1] ?? "", 10); const height = Number.parseInt(match[2] ?? "", 10); if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return null; return { width, height }; } function mapResolutionToEdge(resolution) { if (!resolution) return; return resolution === "4K" ? 4096 : resolution === "2K" ? 2048 : 1024; } function aspectRatioToEnum(aspectRatio) { const normalized = aspectRatio?.trim(); if (!normalized) return; if (normalized === "1:1") return "square_hd"; if (normalized === "4:3") return "landscape_4_3"; if (normalized === "3:4") return "portrait_4_3"; if (normalized === "16:9") return "landscape_16_9"; if (normalized === "9:16") return "portrait_16_9"; } function parseAspectRatioParts(aspectRatio) { const match = /^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/u.exec(aspectRatio.trim()); if (!match) throw new Error(`Invalid fal aspect ratio: ${aspectRatio}`); const widthRatio = Number.parseFloat(match[1] ?? ""); const heightRatio = Number.parseFloat(match[2] ?? ""); if (!Number.isFinite(widthRatio) || !Number.isFinite(heightRatio) || widthRatio <= 0 || heightRatio <= 0) throw new Error(`Invalid fal aspect ratio: ${aspectRatio}`); return { widthRatio, heightRatio }; } function aspectRatioToDimensions(aspectRatio, edge) { const { widthRatio, heightRatio } = parseAspectRatioParts(aspectRatio); if (widthRatio >= heightRatio) return { width: edge, height: Math.max(1, Math.round(edge * heightRatio / widthRatio)) }; return { width: Math.max(1, Math.round(edge * widthRatio / heightRatio)), height: edge }; } function resolveFalImageSize(params) { const parsed = parseSize(params.size); if (parsed) return parsed; const normalizedAspectRatio = params.aspectRatio?.trim(); if (normalizedAspectRatio && params.hasInputImages) return aspectRatioToEnum(normalizedAspectRatio) ?? aspectRatioToDimensions(normalizedAspectRatio, 1024); const edge = mapResolutionToEdge(params.resolution); if (normalizedAspectRatio && edge) return aspectRatioToDimensions(normalizedAspectRatio, edge); if (edge) return { width: edge, height: edge }; if (normalizedAspectRatio) return aspectRatioToEnum(normalizedAspectRatio) ?? aspectRatioToDimensions(normalizedAspectRatio, 1024); } function aspectRatioScore(aspectRatio, targetRatio) { const { widthRatio, heightRatio } = parseAspectRatioParts(aspectRatio); return Math.abs(Math.log(widthRatio / heightRatio) - Math.log(targetRatio)); } function resolveClosestFalAspectRatioForSize(imageSize, aspectRatios) { if (!imageSize || typeof imageSize === "string") return; const targetRatio = imageSize.width / imageSize.height; return aspectRatios.reduce((best, candidate) => { if (!best) return candidate; return aspectRatioScore(candidate, targetRatio) < aspectRatioScore(best, targetRatio) ? candidate : best; }, void 0); } function resolveKreaCreativity(raw) { const normalized = normalizeLowercaseStringOrEmpty(raw); return KREA_CREATIVITY_LEVELS.includes(normalized) ? normalized : "medium"; } function resolveFalCreativityOption(providerOptions) { const falOptions = isRecord(providerOptions?.fal) ? providerOptions.fal : void 0; return typeof falOptions?.creativity === "string" ? falOptions.creativity : ""; } function resolveNativeFalAspectRatio(params) { const requestedAspectRatio = params.aspectRatio?.trim(); const allowedAspectRatios = params.schema.aspectRatios; if (requestedAspectRatio) { if (allowedAspectRatios && !allowedAspectRatios.includes(requestedAspectRatio)) throw new Error(`${params.schema.referenceLimitLabel} supports aspectRatio values: ${allowedAspectRatios.join(", ")}`); return requestedAspectRatio; } if (allowedAspectRatios) return resolveClosestFalAspectRatioForSize(params.imageSize, allowedAspectRatios); } function applyFalImageGeometry(params) { if (params.schema.geometry === "native_aspect_ratio") { if (params.resolution && params.schema.referenceImages === "image_style_references") throw new Error("fal Krea 2 supports aspectRatio but not resolution overrides"); const nativeAspectRatio = resolveNativeFalAspectRatio({ schema: params.schema, aspectRatio: params.aspectRatio, imageSize: params.size ? params.imageSize : void 0 }); if (nativeAspectRatio) params.requestBody.aspect_ratio = nativeAspectRatio; if (params.resolution && params.schema.referenceImages === "image_urls") params.requestBody.resolution = params.resolution; return; } if (params.imageSize !== void 0) params.requestBody.image_size = params.imageSize; } function applyFalReferenceImages(params) { const encoded = params.inputImages.map((img) => toImageDataUrl(img)); if (params.schema.referenceImages === "image_urls") { params.requestBody.image_urls = encoded; return; } if (params.schema.referenceImages === "image_style_references") { params.requestBody.image_style_references = encoded.map((imageUrl) => ({ image_url: imageUrl })); return; } const [input] = encoded; if (!input) throw new Error("fal image edit request missing reference image"); params.requestBody.image_url = input; } function formatFalReferenceLimitError(schema, inputImageCount) { const limit = schema.maxInputImages === 1 ? "one" : String(schema.maxInputImages); const noun = schema.maxInputImages === 1 ? schema.referenceLimitNoun : `${schema.referenceLimitNoun}s`; return `${schema.referenceLimitLabel} supports at most ${limit} ${noun} (requested ${inputImageCount})`; } function resolveGeneratedImageMaxBytes(req) { const configured = req.cfg.agents?.defaults?.mediaMaxMb; if (typeof configured === "number" && Number.isFinite(configured) && configured > 0) return Math.floor(configured * 1024 * 1024); return DEFAULT_GENERATED_IMAGE_MAX_BYTES; } async function fetchImageBuffer(url, networkPolicy, maxBytes = DEFAULT_GENERATED_IMAGE_MAX_BYTES) { const downloadPolicy = (() => { const trustedSuffix = networkPolicy?.trustedDownloadHostSuffix; const trustedPolicy = networkPolicy?.trustedDownloadPolicy; if (!trustedSuffix || !trustedPolicy) return; try { return matchesTrustedHostSuffix(new URL(url).hostname, trustedSuffix) ? trustedPolicy : void 0; } catch { return; } })(); const { response, release } = await falFetchGuard({ url, policy: downloadPolicy, auditContext: "fal-image-download" }); try { await assertOkOrThrowProviderError(response, "fal image download failed"); const mimeType = response.headers.get("content-type")?.trim() || "image/png"; return { buffer: await readResponseWithLimit(response, maxBytes, { onOverflow: ({ maxBytes: maxBytesLocal }) => /* @__PURE__ */ new Error(`fal generated image download exceeds ${maxBytesLocal} bytes`) }), mimeType }; } finally { await release(); } } function buildFalImageGenerationProvider() { return { id: "fal", label: "fal", defaultModel: DEFAULT_FAL_IMAGE_MODEL, models: [ DEFAULT_FAL_IMAGE_MODEL, `${DEFAULT_FAL_IMAGE_MODEL}/${DEFAULT_FAL_EDIT_SUBPATH}`, FAL_KREA_2_MEDIUM_MODEL, FAL_KREA_2_LARGE_MODEL ], isConfigured: ({ agentDir }) => isProviderApiKeyConfigured({ provider: "fal", agentDir }), capabilities: { generate: { maxCount: 4, supportsSize: true, supportsAspectRatio: true, supportsResolution: true }, edit: { enabled: true, maxCount: 4, maxInputImages: GPT_IMAGE_EDIT_MAX_INPUT_IMAGES, supportsSize: true, supportsAspectRatio: true, supportsResolution: true }, geometry: { sizes: [...FAL_SUPPORTED_SIZES], sizesByModel: { [FAL_KREA_2_MEDIUM_MODEL]: [], [FAL_KREA_2_LARGE_MODEL]: [] }, aspectRatios: [...FAL_SUPPORTED_ASPECT_RATIOS], resolutions: [ "1K", "2K", "4K" ] }, output: { formats: [...FAL_OUTPUT_FORMATS] } }, async generateImage(req) { const inputImageCount = req.inputImages?.length ?? 0; const hasInputImages = inputImageCount > 0; const requestedModel = req.model?.trim() || DEFAULT_FAL_IMAGE_MODEL; const schema = resolveFalImageModelSchema(requestedModel); const imageSize = resolveFalImageSize({ size: req.size, resolution: req.resolution, aspectRatio: req.aspectRatio, hasInputImages }); const model = ensureFalModelPath(req.model, hasInputImages); if (hasInputImages && inputImageCount > schema.maxInputImages) throw new Error(formatFalReferenceLimitError(schema, inputImageCount)); if (hasInputImages && schema.referenceImages === "image_url") { if (req.aspectRatio) throw new Error("fal flux image edit endpoint does not support aspectRatio overrides"); } if (!schema.supportsCount && (req.count ?? 1) > 1) throw new Error(`fal ${requestedModel} supports one output image per request`); if (!schema.supportsOutputFormat && req.outputFormat) throw new Error(`fal ${requestedModel} does not support outputFormat overrides`); const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } = await resolveFalHttpRequestConfig({ req, capability: "image" }); const networkPolicy = resolveFalNetworkPolicy({ baseUrl, allowPrivateNetwork }); const maxImageBytes = resolveGeneratedImageMaxBytes(req); const requestBody = { prompt: req.prompt, ...schema.supportsCount ? { num_images: req.count ?? 1 } : {}, ...schema.supportsOutputFormat ? { output_format: req.outputFormat ?? DEFAULT_OUTPUT_FORMAT } : {}, ...schema.defaultBody }; if (schema.referenceImages === "image_style_references") requestBody.creativity = resolveKreaCreativity(resolveFalCreativityOption(req.providerOptions)); applyFalImageGeometry({ requestBody, schema, imageSize, size: req.size, aspectRatio: req.aspectRatio, resolution: req.resolution, hasInputImages }); if (hasInputImages) applyFalReferenceImages({ requestBody, schema, inputImages: req.inputImages ?? [] }); const { response, release } = await falFetchGuard({ url: `${baseUrl}/${model}`, init: { method: "POST", headers, body: JSON.stringify(requestBody) }, timeoutMs: req.timeoutMs, policy: networkPolicy.apiPolicy, dispatcherPolicy, auditContext: "fal-image-generate" }); try { await assertOkOrThrowHttpError(response, "fal image generation failed"); const payload = parseFalImageGenerationResponse(await response.json()); const images = []; let imageIndex = 0; for (const entry of payload.images) { const url = normalizeOptionalString(entry.url); if (!url) throw new Error(FAL_IMAGE_MALFORMED_RESPONSE); const downloaded = await fetchImageBuffer(url, networkPolicy, maxImageBytes); imageIndex += 1; images.push({ buffer: downloaded.buffer, mimeType: downloaded.mimeType, fileName: `image-${imageIndex}.${imageFileExtensionForMimeType(downloaded.mimeType || normalizeOptionalString(entry.content_type))}` }); } if (images.length === 0) throw new Error("fal image generation response missing image data"); return { images, model, metadata: payload.prompt ? { prompt: payload.prompt } : void 0 }; } finally { await release(); } } }; } //#endregion export { setFalFetchGuardForTesting as n, buildFalImageGenerationProvider as t };