From d19efcc4050c2969479691367ced69812debfc89 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Tue, 15 Sep 2026 16:19:29 -0700 Subject: [PATCH] Support explicitly selected fal Flare in hosted Easel images --- easel/media/picture/illy.mjs | 163 +++++++++++++++++++++++ system/backend/easel-images.mjs | 17 ++- system/netlify/functions/easel-image.mjs | 5 +- system/tests/easel-images.test.mjs | 28 ++++ 4 files changed, 209 insertions(+), 4 deletions(-) create mode 100644 easel/media/picture/illy.mjs diff --git a/easel/media/picture/illy.mjs b/easel/media/picture/illy.mjs new file mode 100644 index 0000000000..047d45e983 --- /dev/null +++ b/easel/media/picture/illy.mjs @@ -0,0 +1,163 @@ +// Portable Illy adapter. Deliberately no studio vault or implicit provider fallback. +import { createHash } from "node:crypto"; +// Schema: https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image/api +export const FAL_FLARE = "openai/gpt-image-2.5/flare/text-to-image"; +export const models = Object.freeze({ + openai: ["gpt-image-2"], + fal: ["fal-ai/flux/dev", "fal-ai/flux/dev/image-to-image", FAL_FLARE], +}); +export function plan(input) { + const { provider, model, prompt } = input; + if (!models[provider]?.includes(model)) + throw new Error("Select an explicit supported image provider/model."); + if (typeof prompt !== "string" || !prompt.trim() || prompt.length > 16000) + throw new Error("An image prompt is required (maximum 16000 characters)."); + const size = input.size || "1024x1024"; + if (!["1024x1024", "1024x1536", "1536x1024"].includes(size)) + throw new Error("Unsupported image size."); + const quality = input.quality || "medium"; + if (!["low", "medium", "high"].includes(quality)) + throw new Error("Unsupported quality."); + const edit = !!input.reference; + if ( + provider === "fal" && + edit !== (model === "fal-ai/flux/dev/image-to-image") + ) + throw new Error("Choose the fal model matching generate or edit."); + return { + provider, + model, + prompt, + size, + quality, + mode: edit ? "edit" : "generate", + promptHash: createHash("sha256").update(prompt).digest("hex"), + stages: ["generate", "validate", "propose"], + }; +} +export async function generate( + plan, + { + reference, + env = process.env, + fetchImpl = fetch, + signal = AbortSignal.timeout(600000), + onSubmitted = async () => {}, + } = {}, +) { + const key = env[plan.provider === "openai" ? "OPENAI_API_KEY" : "FAL_KEY"]; + if (!key) + throw new Error( + `Set ${plan.provider === "openai" ? "OPENAI_API_KEY" : "FAL_KEY"} outside the project to use this provider.`, + ); + const checked = async (url, options = {}) => { + const r = await fetchImpl(url, { ...options, signal, redirect: "error" }); + if (!r.ok) + throw new Error( + `Image provider HTTP ${r.status}; request was not retried.`, + ); + return r; + }; + // Never automatically retry a paid POST: a network failure may have been charged. + let result, requestId, bytes; + if (plan.provider === "openai") { + let body, + headers = { Authorization: `Bearer ${key}` }; + if (reference) { + body = new FormData(); + for (const [k, v] of Object.entries({ + model: plan.model, + prompt: plan.prompt, + size: plan.size, + quality: plan.quality, + n: "1", + })) + body.append(k, v); + body.append( + "image[]", + new Blob([reference], { type: "image/png" }), + "reference.png", + ); + } else { + headers["Content-Type"] = "application/json"; + body = JSON.stringify({ + model: plan.model, + prompt: plan.prompt, + size: plan.size, + quality: plan.quality, + n: 1, + output_format: "png", + }); + } + const response = await checked( + `https://api.openai.com/v1/images/${reference ? "edits" : "generations"}`, + { method: "POST", headers, body }, + ); + result = await response.json(); + requestId = response.headers?.get("x-request-id") || result.id || null; + if (result.data?.[0]?.b64_json) + bytes = Buffer.from(result.data[0].b64_json, "base64"); + else throw new Error("Image provider returned no PNG bytes."); + } else { + const [width, height] = plan.size.split("x").map(Number), + headers = { + Authorization: `Key ${key}`, + "Content-Type": "application/json", + }; + const input = { + prompt: plan.prompt, + image_size: { width, height }, + num_images: 1, + output_format: "png", + }; + if (plan.model === FAL_FLARE) input.quality = plan.quality; + if (reference) + input.image_url = `data:image/png;base64,${reference.toString("base64")}`; + const queued = await ( + await checked(`https://queue.fal.run/${plan.model}`, { + method: "POST", + headers, + body: JSON.stringify(input), + }) + ).json(); + requestId = queued.request_id; + const queueURL = (value) => { + const u = new URL(value); + if (u.protocol !== "https:" || u.hostname !== "queue.fal.run") + throw new Error("Unexpected fal queue URL."); + return u.href; + }; + await onSubmitted({ + requestId, + statusURL: queueURL(queued.status_url), + responseURL: queueURL(queued.response_url), + }); + for (;;) { + signal.throwIfAborted(); + const status = await ( + await checked(queueURL(queued.status_url), { headers }) + ).json(); + if (status.status === "COMPLETED") break; + if (status.status === "FAILED" || status.error) + throw new Error("fal generation failed."); + await new Promise((r) => setTimeout(r, 1000)); + } + result = await ( + await checked(queueURL(queued.response_url), { headers }) + ).json(); + const url = new URL(result.images?.[0]?.url); + if (url.protocol !== "https:" || !/(^|\.)fal\.media$/.test(url.hostname)) + throw new Error("Unexpected fal image host."); + bytes = Buffer.from(await (await checked(url)).arrayBuffer()); + } + if (bytes.length > 32 * 1024 * 1024) throw new Error("Image exceeds 32 MB."); + return { + bytes, + provenance: { + ...plan, + requestId, + usage: result.usage || null, + completedAt: new Date().toISOString(), + }, + }; +} diff --git a/system/backend/easel-images.mjs b/system/backend/easel-images.mjs index 7b88cd1b76..e1866ae256 100644 --- a/system/backend/easel-images.mjs +++ b/system/backend/easel-images.mjs @@ -1,14 +1,27 @@ import sharp from 'sharp'; +import { generate as generateProviderImage, FAL_FLARE } from '../../easel/media/picture/illy.mjs'; +export const hostedImageModels = Object.freeze({openai: ['gpt-image-2'], fal: [FAL_FLARE]}); export function imageRequest(input) { if (!/^[a-zA-Z0-9_-]{16,80}$/.test(input.jobId || '')) throw new Error('Invalid image job.'); if (typeof input.prompt !== 'string' || !input.prompt.trim() || input.prompt.length > 16000) throw new Error('Provide an image prompt.'); - if (input.model && input.model !== 'gpt-image-2') throw new Error('Unsupported image model.'); + const provider=input.provider || 'openai'; + const model=input.model || (provider === 'openai' ? 'gpt-image-2' : null); + if (!hostedImageModels[provider]?.includes(model)) throw new Error('Unsupported hosted image provider/model.'); + if (provider === 'fal' && input.reference) throw new Error('This fal model supports generation only, not reference edits.'); const size=input.size || '1024x1024', quality=input.quality || 'medium'; if (!['1024x1024','1536x1024','1024x1536'].includes(size) || !['low','medium','high'].includes(quality)) throw new Error('Unsupported image settings.'); if(input.reference && (typeof input.reference!=='string' || input.reference.length>12*1024*1024 || !/^[A-Za-z0-9+/]+={0,2}$/.test(input.reference)))throw new Error('Invalid reference image.'); - return {jobId:input.jobId,prompt:input.prompt,model:'gpt-image-2',size,quality,reference:input.reference}; + return {jobId:input.jobId,prompt:input.prompt,provider,model,size,quality,reference:input.reference}; } export async function generateEaselImage(input,{key,fetchImpl=fetch}={}) { + input=imageRequest(input); + if (!key) throw new Error('Selected image provider is not configured.'); + if (input.provider === 'fal') { + const result=await generateProviderImage({...input, mode:'generate'}, {env:{FAL_KEY:key},fetchImpl,signal:AbortSignal.timeout(300000)}); + const bytes=await sharp(result.bytes,{limitInputPixels:16*1024*1024}).ensureAlpha().png().toBuffer(); + if(bytes.length>8*1024*1024)throw new Error('Generated image is too large.'); + return {png:bytes.toString('base64'),provenance:{provider:'fal',model:input.model,mode:'generate',requestId:result.provenance.requestId,usage:result.provenance.usage,completedAt:result.provenance.completedAt}}; + } let body, headers={Authorization:`Bearer ${key}`}; const parameters={model:input.model,prompt:input.prompt,size:input.size,quality:input.quality,n:1,output_format:'png'}; if(input.reference) { diff --git a/system/netlify/functions/easel-image.mjs b/system/netlify/functions/easel-image.mjs index 67387c0e51..4fd045798e 100644 --- a/system/netlify/functions/easel-image.mjs +++ b/system/netlify/functions/easel-image.mjs @@ -9,9 +9,10 @@ export async function handler(event) { if(!user?.sub)return respond(401,{error:'Sign in to use hosted image tools.'},headers); const handle=await getHandleOrEmail(user.sub); if(!handle?.startsWith('@'))return respond(403,{error:'Claim an AC handle first.'},headers); - if(!process.env.OPENAI_API_KEY)return respond(503,{error:'Hosted image tools are unavailable.'},headers); let input; try{input=imageRequest(JSON.parse(event.body));}catch(error){return respond(400,{error:error.message},headers);} + const key=process.env[input.provider === 'fal' ? 'FAL_KEY' : 'OPENAI_API_KEY']; + if(!key)return respond(503,{error:`Hosted ${input.provider} image tools are unavailable.`},headers); const {db}=await connect(); const jobs=db.collection('easel-image-jobs'); await jobs.createIndex({expiresAt:1},{expireAfterSeconds:0}); @@ -29,7 +30,7 @@ export async function handler(event) { if(!allowed.modifiedCount){await jobs.updateOne({_id:id},{$set:{status:'limited'}});return respond(429,{error:'Today’s five hosted images have been used. Try tomorrow or use your own provider key.'},headers);} try { await jobs.updateOne({_id:id},{$set:{status:'submitted'}}); - const result=await generateEaselImage(input,{key:process.env.OPENAI_API_KEY}); + const result=await generateEaselImage(input,{key}); await jobs.updateOne({_id:id},{$set:{status:'complete',result}}); return respond(200,result,headers); }catch(error){ diff --git a/system/tests/easel-images.test.mjs b/system/tests/easel-images.test.mjs index e9a51efc36..ef1c77ff95 100644 --- a/system/tests/easel-images.test.mjs +++ b/system/tests/easel-images.test.mjs @@ -24,3 +24,31 @@ test('invalid requests and upstream failures never make duplicate paid requests' await assert.rejects(generateEaselImage(imageRequest({jobId:'test-job-00000001',prompt:'x'}),{key:'test',fetchImpl:async()=>{calls++;return new Response('',{status:503});}}),/not retried/); assert.equal(calls,1); }); +test('hosted fal Flare uses official queue schema and server credentials, returning PNG provenance',async()=>{ + const model='openai/gpt-image-2.5/flare/text-to-image'; + const png=await sharp({create:{width:2,height:2,channels:4,background:'#ff77aa'}}).png().toBuffer(); + const calls=[]; + const input=imageRequest({jobId:'test-fal-00000001',provider:'fal',model,prompt:'A pink circle',size:'1536x1024',quality:'low'}); + const result=await generateEaselImage(input,{key:'server-fal-secret',fetchImpl:async(url,options)=>{ + url=String(url);calls.push({url,...options}); + if(options.method==='POST'){ + assert.equal(url,`https://queue.fal.run/${model}`); assert.equal(options.headers.Authorization,'Key server-fal-secret'); + assert.deepEqual(JSON.parse(options.body),{prompt:'A pink circle',image_size:{width:1536,height:1024},num_images:1,output_format:'png',quality:'low'}); + return Response.json({request_id:'fal-request',status_url:'https://queue.fal.run/openai/requests/fal-request/status',response_url:'https://queue.fal.run/openai/requests/fal-request'}); + } + if(url.endsWith('/status'))return Response.json({status:'COMPLETED'}); + if(url.startsWith('https://queue.fal.run/'))return Response.json({images:[{url:'https://v3.fal.media/generated.png'}]}); + assert.equal(options.headers,undefined); assert.equal(options.redirect,'error'); return new Response(png); + }}); + assert.equal(calls.filter(c=>c.method==='POST').length,1); assert.equal(result.provenance.provider,'fal'); assert.equal(result.provenance.model,model); assert.equal(result.provenance.requestId,'fal-request'); + assert.equal((await sharp(Buffer.from(result.png,'base64')).metadata()).width,2); + assert.equal(JSON.stringify(result).includes('server-fal-secret'),false); +}); +test('hosted allowlist rejects wrong providers, implicit fal model, and Flare edits before spending',async()=>{ + const base={jobId:'test-fal-00000002',prompt:'A circle',provider:'fal',model:'openai/gpt-image-2.5/flare/text-to-image'}; + for(const input of [{...base,provider:'other'},{...base,model:undefined},{...base,model:'fal-ai/flux/dev'},{...base,reference:'YWJj'}]) assert.throws(()=>imageRequest(input),/provider\/model|generation only/); + let calls=0; + await assert.rejects(generateEaselImage(imageRequest(base),{key:'test',fetchImpl:async()=>{calls++;return new Response('',{status:503});}}),/not retried/); + assert.equal(calls,1); + await assert.rejects(generateEaselImage(imageRequest(base),{fetchImpl:()=>assert.fail('must not spend')}),/not configured/); +}); -- 2.51.2