diff --git a/system/backend/easel-images.mjs b/system/backend/easel-images.mjs new file mode 100644 index 0000000000..7b88cd1b76 --- /dev/null +++ b/system/backend/easel-images.mjs @@ -0,0 +1,26 @@ +import sharp from 'sharp'; +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 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}; +} +export async function generateEaselImage(input,{key,fetchImpl=fetch}={}) { + 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) { + const image=await sharp(Buffer.from(input.reference,'base64'),{limitInputPixels:16*1024*1024}).png().toBuffer(); + body=new FormData();for(const [name,value] of Object.entries(parameters))body.append(name,String(value)); + body.append('image[]',new Blob([image],{type:'image/png'}),'painting.png'); + } else {headers['Content-Type']='application/json';body=JSON.stringify(parameters);} + const response=await fetchImpl(`https://api.openai.com/v1/images/${input.reference?'edits':'generations'}`,{method:'POST',headers,body,signal:AbortSignal.timeout(300000)}); + if(!response.ok)throw new Error(`Image provider returned HTTP ${response.status}. The request was not retried.`); + const result=await response.json(); + if(!result.data?.[0]?.b64_json)throw new Error('Image provider returned no image.'); + const bytes=await sharp(Buffer.from(result.data[0].b64_json,'base64'),{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:'openai',model:input.model,mode:input.reference?'edit':'generate',requestId:response.headers.get('x-request-id'),usage:result.usage||null,completedAt:new Date().toISOString()}}; +} diff --git a/system/netlify/functions/easel-image.mjs b/system/netlify/functions/easel-image.mjs new file mode 100644 index 0000000000..67387c0e51 --- /dev/null +++ b/system/netlify/functions/easel-image.mjs @@ -0,0 +1,40 @@ +import { authorize, getHandleOrEmail } from '../../backend/authorization.mjs'; +import { connect } from '../../backend/database.mjs'; +import { respond } from '../../backend/http.mjs'; +import { imageRequest, generateEaselImage } from '../../backend/easel-images.mjs'; +const headers={'Cache-Control':'no-store'}; +export async function handler(event) { + if(event.httpMethod!=='POST')return respond(405,{error:'POST required.'},headers); + const user=await authorize(event.headers); + 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 {db}=await connect(); + const jobs=db.collection('easel-image-jobs'); + await jobs.createIndex({expiresAt:1},{expireAfterSeconds:0}); + const id=`${user.sub}:${input.jobId}`; + const existing=await jobs.findOne({_id:id}); + if(existing)return existing.result?respond(200,existing.result,headers):respond(409,{error:'This image job was already submitted. It will not be charged twice.',status:existing.status},headers); + // Reserve a bounded daily allowance before the paid request. The same job id + // always refers to one request, including when the client loses its response. + const budget=db.collection('easel-image-budget'); + await budget.createIndex({expiresAt:1},{expireAfterSeconds:0}); + const day=new Date().toISOString().slice(0,10), budgetId=`${user.sub}:${day}`; + await budget.updateOne({_id:budgetId},{$setOnInsert:{count:0,expiresAt:new Date(Date.now()+2*86400000)}},{upsert:true}); + try{await jobs.insertOne({_id:id,status:'reserved',expiresAt:new Date(Date.now()+7*86400000)});}catch(error){if(error.code===11000)return respond(409,{error:'This image job is already running.'},headers);throw error;} + const allowed=await budget.updateOne({_id:budgetId,count:{$lt:5}},{$inc:{count:1}}); + 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}); + await jobs.updateOne({_id:id},{$set:{status:'complete',result}}); + return respond(200,result,headers); + }catch(error){ + await jobs.updateOne({_id:id},{$set:{status:'failed-or-unknown'}}); + console.warn('Easel image failed:',error.message); + return respond(502,{error:error.message},headers); + } +} diff --git a/system/tests/easel-images.test.mjs b/system/tests/easel-images.test.mjs new file mode 100644 index 0000000000..e9a51efc36 --- /dev/null +++ b/system/tests/easel-images.test.mjs @@ -0,0 +1,26 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import sharp from 'sharp'; +import {imageRequest,generateEaselImage} from '../backend/easel-images.mjs'; +test('hosted image tools use generation or an actual reference edit, retaining provenance',async()=>{ + const png=await sharp({create:{width:2,height:2,channels:4,background:'#ffff00'}}).png().toBuffer(); + for(const reference of [undefined,png.toString('base64')]) { + const input=imageRequest({jobId:'test-job-00000001',prompt:'A small yellow square',reference}); + let calls=0; + const result=await generateEaselImage(input,{key:'test-secret',fetchImpl:async(url,options)=>{ + calls++;assert.match(url,reference?/\/edits$/:/\/generations$/);assert.equal(options.headers.Authorization,'Bearer test-secret'); + if(reference) {assert(options.body instanceof FormData);assert.equal(options.body.get('image[]').type,'image/png');} + else assert.equal(JSON.parse(options.body).output_format,'png'); + return new Response(JSON.stringify({data:[{b64_json:png.toString('base64')}]}),{headers:{'x-request-id':'test-request'}}); + }}); + assert.equal(calls,1);assert.equal(result.provenance.mode,reference?'edit':'generate');assert.equal(result.provenance.requestId,'test-request'); + assert.equal((await sharp(Buffer.from(result.png,'base64')).metadata()).width,2); + } +}); +test('invalid requests and upstream failures never make duplicate paid requests',async()=>{ + assert.throws(()=>imageRequest({jobId:'x',prompt:'x'}),/job/); + assert.throws(()=>imageRequest({jobId:'test-job-00000001',prompt:'x',model:'another'}),/model/); + let calls=0; + 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); +});