diff --git a/lith/.env.example b/lith/.env.example index ffc949914b..93edcf6a98 100644 --- a/lith/.env.example +++ b/lith/.env.example @@ -35,3 +35,7 @@ DEPLOY_SECRET=replace-with-production-secret # CLOUDFLARE_AI_TOKEN=... IMAGE_MONTHLY_BUDGET_USD=5 IMAGE_DAILY_BUDGET_USD=0.5 + +# Optional Sana overflow after confirmed Cloudflare daily quota exhaustion. +# Reuse the studio fal.ai credential without enabling other FAL_KEY consumers. +# IMAGE_FAL_KEY=... diff --git a/lith/README.md b/lith/README.md index fa1b9396c7..8f96578297 100644 --- a/lith/README.md +++ b/lith/README.md @@ -54,16 +54,25 @@ this key so deployment validation rejects incomplete environments. 1024×1024. Set `CLOUDFLARE_ACCOUNT_ID` and a scoped `CLOUDFLARE_AI_TOKEN` in the canonical Lith env. `IMAGE_MONTHLY_BUDGET_USD` defaults to 5; `IMAGE_DAILY_BUDGET_USD` defaults to 0.5. Set either to 0 to stop inference. +Set `IMAGE_FAL_KEY` to enable fal.ai Sana for new requests after Cloudflare +reports daily-quota exhaustion. This separate variable avoids enabling other +products that use `FAL_KEY`. Sana returns 768×768 JPEGs at 18 steps. Mongo collection `image-generation-budget` reserves 634 micro-USD per -attempt before the provider request, including failed attempts. Both caps +Cloudflare attempt and 1,000 micro-USD per Sana attempt before the provider +request, including failed attempts. Both caps are shared across processes and survive restarts, resetting at UTC month/day boundaries. Database failure stops generation. There are no automatic paid -retries or premium fallbacks. The Cloudflare free quota can stop requests -earlier; free-quota exhaustion returns 429 until midnight UTC. +retries or premium fallbacks. Cloudflare quota exhaustion returns 429; with +Sana configured, the next request can use Sana after one second. Without +Sana, requests wait until midnight UTC. Cloudflare is selected again at +midnight. Provider timeouts and other failures never trigger a second paid +request. Both providers debit the same historical `cloudflare-flux:YYYY-MM` +record, so enabling Sana cannot reset the cap. The reservation rounds up the September 2026 four-step image rate of $0.0006336. Revisit it when changing the model, dimensions, steps, or [Cloudflare pricing](https://developers.cloudflare.com/workers-ai/platform/pricing/). -Caps cover this endpoint's inference, excluding account plan fees and other -Cloudflare workloads. +Sana reserves a full megapixel at its $0.001/megapixel rate despite requesting +only 768×768. Caps cover this endpoint's inference, excluding account plan +fees and other workloads. diff --git a/plugins/illy/config/providers.json b/plugins/illy/config/providers.json index f76ed36d99..a547ec3d0e 100644 --- a/plugins/illy/config/providers.json +++ b/plugins/illy/config/providers.json @@ -66,6 +66,16 @@ "reference": "image_url" }, "output": "images.0.url" + }, + "fal-ai/sana": { + "modes": [ + "generate" + ], + "input": { + "prompt": "prompt", + "size": "image_size" + }, + "output": "images.0.url" } } } diff --git a/system/backend/image-generation-budget.mjs b/system/backend/image-generation-budget.mjs index 4a682cc358..ff365903a1 100644 --- a/system/backend/image-generation-budget.mjs +++ b/system/backend/image-generation-budget.mjs @@ -2,6 +2,13 @@ // Cloudflare FLUX Schnell, 1024 square / four steps: $0.0006336, rounded up. export const IMAGE_COST_MICRO_USD = 634; +function providerCost(provider) { + if (provider === "cloudflare") return IMAGE_COST_MICRO_USD; + // fal Sana at 768x768 remains within its one-megapixel price tier. + if (provider === "fal-sana") return 1000; + throw new Error("Unknown image generation provider"); +} + function cap(value, fallback) { const dollars = Number(value ?? fallback); if (!Number.isFinite(dollars) || dollars < 0 || dollars > 1000) @@ -10,13 +17,15 @@ function cap(value, fallback) { } export function createMongoImageBudget(collection, { env = process.env, now = Date.now } = {}) { - return async function reserve() { + return async function reserve({ provider = "cloudflare" } = {}) { + const cost = providerCost(provider); const at = new Date(now()); const month = at.toISOString().slice(0, 7); const day = at.toISOString().slice(0, 10); const monthly = cap(env.IMAGE_MONTHLY_BUDGET_USD, 5); const daily = cap(env.IMAGE_DAILY_BUDGET_USD, 0.5); - const cost = IMAGE_COST_MICRO_USD; + // Keep the historical Cloudflare key: changing providers must not reset + // spend already reserved this month. Daily/monthly totals include both. const id = `cloudflare-flux:${month}`; const field = `days.${day}`; const dayEnd = Date.UTC(at.getUTCFullYear(), at.getUTCMonth(), at.getUTCDate() + 1); @@ -47,8 +56,9 @@ export function createMongoImageBudget(collection, { env = process.env, now = Da }; } -export async function reserveImageBudget() { +export async function reserveImageBudget({ provider = "cloudflare" } = {}) { + providerCost(provider); // Reject unknown providers before opening the database. const { connect } = await import("./database.mjs"); const { db } = await connect(); - return createMongoImageBudget(db.collection("image-generation-budget"))(); + return createMongoImageBudget(db.collection("image-generation-budget"))({ provider }); } diff --git a/system/backend/sana-image.mjs b/system/backend/sana-image.mjs new file mode 100644 index 0000000000..5647061f79 --- /dev/null +++ b/system/backend/sana-image.mjs @@ -0,0 +1,36 @@ +// One bounded inference call. sync_mode keeps the image inline, with no URL fetch. +export async function generateSana({ prompt, key, signal, fetchImpl = fetch }) { + if (!key) throw new Error("Sana is not configured"); + const response = await fetchImpl("https://fal.run/fal-ai/sana", { + method: "POST", + redirect: "error", + headers: { Authorization: `Key ${key}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + prompt, image_size: { width: 768, height: 768 }, num_images: 1, + num_inference_steps: 18, sync_mode: true, output_format: "jpeg", + enable_safety_checker: true, + }), + signal, + }); + if (!response.ok) { + await response.body?.cancel(); + throw Object.assign(new Error(`Sana HTTP ${response.status}`), { + status: response.status, retryAfter: response.headers.get("Retry-After"), + }); + } + const result = await response.json(); + if (result?.has_nsfw_concepts?.some(Boolean)) + throw Object.assign(new Error("Sana filtered the image"), { reason: "filtered" }); + const image = result?.images?.[0]; + if (result?.images?.length !== 1 || image.width !== 768 || image.height !== 768 || + typeof image.url !== "string" || image.url.length > 12 * 1024 * 1024 || + !/^data:image\/jpeg;base64,[A-Za-z0-9+/]+={0,2}$/.test(image.url)) + throw Object.assign(new Error("Sana returned an invalid image"), { reason: "invalid_image" }); + const bytes = Buffer.from(image.url.slice(image.url.indexOf(",") + 1), "base64"); + if (bytes.length > 8 * 1024 * 1024 || bytes[0] !== 0xff || bytes[1] !== 0xd8 || bytes[2] !== 0xff) + throw Object.assign(new Error("Sana returned invalid JPEG bytes"), { reason: "invalid_image" }); + return { + png: image.url, width: 768, height: 768, + seed: Number.isInteger(result.seed) ? result.seed : null, + }; +} diff --git a/system/netlify/functions/flux.mjs b/system/netlify/functions/flux.mjs index 22aeed53ad..760a3a52c2 100644 --- a/system/netlify/functions/flux.mjs +++ b/system/netlify/functions/flux.mjs @@ -1,10 +1,11 @@ // flux, 26.09.21 -// Budgeted Cloudflare FLUX.1 schnell image generation. Provider keys stay server-side. +// Budgeted Cloudflare FLUX.1 schnell, with Sana for new requests after its daily quota. // POST { prompt, preset?: "kidlisp" | "warm" | "raw" }. -// The hosted model uses four steps and returns a fixed 1024×1024 image. +// Cloudflare returns 1024×1024 images; Sana returns 768×768. Keys stay server-side. import { respond } from "../../backend/http.mjs"; import { reserveImageBudget } from "../../backend/image-generation-budget.mjs"; +import { generateSana } from "../../backend/sana-image.mjs"; const MODEL = "@cf/black-forest-labs/flux-1-schnell"; const TIMEOUT_MS = 30000; @@ -62,28 +63,36 @@ const PRESETS = { export function createHandler({ fetch: providerFetch = (...args) => globalThis.fetch(...args), reserveBudget = reserveImageBudget, + generateSana: sanaGenerate = generateSana, env = process.env, now = Date.now, } = {}) { - let outageUntil = 0; + const circuits = { + cloudflare: { outageUntil: 0, failures: 0, probeInFlight: false }, + "fal-sana": { outageUntil: 0, failures: 0, probeInFlight: false }, + }; let quotaUntil = 0; - let failures = 0; - let probeInFlight = false; let inFlight = 0; - function openCircuit(providerRetry = 0) { - failures += 1; - outageUntil = + function openCircuit(circuit, providerRetry = 0) { + circuit.failures += 1; + circuit.outageUntil = now() + Math.max( providerRetry, - Math.min(COOLDOWN_MS * 2 ** Math.min(failures - 1, 4), MAX_COOLDOWN_MS), + Math.min( + COOLDOWN_MS * 2 ** Math.min(circuit.failures - 1, 4), + MAX_COOLDOWN_MS, + ), ); } - function unavailable() { + function unavailable(circuit) { return retryResponse( 503, - Math.max(probeInFlight ? TIMEOUT_MS : 1000, outageUntil - now()) / 1000, + Math.max( + circuit.probeInFlight ? TIMEOUT_MS : 1000, + circuit.outageUntil - now(), + ) / 1000, ); } @@ -113,28 +122,36 @@ export function createHandler({ const fullPrompt = PRESETS[presetName] ? `${prompt} — ${PRESETS[presetName]}` : prompt; - if (!env.CLOUDFLARE_AI_TOKEN || !env.CLOUDFLARE_ACCOUNT_ID) { - return retryResponse(503, 60, "provider_unavailable"); - } - if (quotaUntil > now()) { - return retryResponse( - 429, - (quotaUntil - now()) / 1000, - "image_budget_exhausted", - ); + // Provider selection happens once, before reservation or inference. Only + // a confirmed daily Cloudflare quota switches future requests to Sana. + const provider = + quotaUntil > now() && env.IMAGE_FAL_KEY ? "fal-sana" : "cloudflare"; + if (provider === "cloudflare") { + if (!env.CLOUDFLARE_AI_TOKEN || !env.CLOUDFLARE_ACCOUNT_ID) { + return retryResponse(503, 60, "provider_unavailable"); + } + if (quotaUntil > now()) { + return retryResponse( + 429, + (quotaUntil - now()) / 1000, + "image_budget_exhausted", + ); + } } - if (outageUntil > now() || probeInFlight) return unavailable(); + const circuit = circuits[provider]; + if (circuit.outageUntil > now() || circuit.probeInFlight) + return unavailable(circuit); if (inFlight >= MAX_IN_FLIGHT) return retryResponse(429, 5, "busy"); - const isProbe = failures > 0; - if (isProbe) probeInFlight = true; + const isProbe = circuit.failures > 0; + if (isProbe) circuit.probeInFlight = true; inFlight += 1; let timeout; const startedAt = now(); try { let budget; try { - budget = await reserveBudget(); + budget = await reserveBudget({ provider }); } catch { console.warn("flux: image budget unavailable"); return retryResponse(503, 60, "budget_unavailable"); @@ -151,6 +168,43 @@ export function createHandler({ // Keep the deadline active until the entire image response is read. const controller = new AbortController(); timeout = setTimeout(() => controller.abort(), TIMEOUT_MS); + if (provider === "fal-sana") { + try { + const image = await sanaGenerate({ + prompt: fullPrompt, + fetchImpl: providerFetch, + signal: controller.signal, + key: env.IMAGE_FAL_KEY, + }); + if ( + !image?.png?.startsWith("data:image/") || + image.width !== 768 || + image.height !== 768 + ) { + throw new Error("Invalid Sana image response"); + } + circuit.failures = 0; + circuit.outageUntil = 0; + return reply(200, { + ok: true, + png: image.png, + width: image.width, + height: image.height, + seed: Number.isInteger(image.seed) ? image.seed : null, + provider, + preset: presetName, + elapsed_ms: now() - startedAt, + }); + } catch (error) { + if (error?.reason === "filtered") { + return reply(422, { ok: false, reason: "filtered" }); + } + console.warn("flux: Sana request failed", error?.name || "unknown"); + openCircuit(circuit, retryAfterMs(error?.retryAfter, now())); + return unavailable(circuit); + } + } + const endpoint = `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(env.CLOUDFLARE_ACCOUNT_ID)}/ai/run/${MODEL}`; let upstream, data; try { @@ -169,8 +223,11 @@ export function createHandler({ "flux: Cloudflare request failed", error?.name || "unknown", ); - openCircuit(retryAfterMs(upstream?.headers?.get("Retry-After"), now())); - return unavailable(); + openCircuit( + circuit, + retryAfterMs(upstream?.headers?.get("Retry-After"), now()), + ); + return unavailable(circuit); } // Cloudflare's free allocation is a daily limit, not transient capacity. @@ -182,12 +239,14 @@ export function createHandler({ data.errors.some((error) => Number(error?.code) === 3036) ) { quotaUntil = (Math.floor(now() / 86400000) + 1) * 86400000; - failures = 0; - outageUntil = 0; + circuit.failures = 0; + circuit.outageUntil = 0; + // Do not repeat this generation with another provider. The caller may + // start a new request once the newly selected route is available. return retryResponse( 429, - (quotaUntil - now()) / 1000, - "image_budget_exhausted", + env.IMAGE_FAL_KEY ? 1 : (quotaUntil - now()) / 1000, + env.IMAGE_FAL_KEY ? "provider_quota" : "image_budget_exhausted", ); } if (!upstream.ok) { @@ -199,8 +258,11 @@ export function createHandler({ upstream.status === 403 || upstream.status >= 500 ) { - openCircuit(retryAfterMs(upstream.headers.get("Retry-After"), now())); - return unavailable(); + openCircuit( + circuit, + retryAfterMs(upstream.headers.get("Retry-After"), now()), + ); + return unavailable(circuit); } return reply(502, { ok: false, @@ -215,11 +277,11 @@ export function createHandler({ !image.length ) { console.warn("flux: Cloudflare response missing image"); - openCircuit(); - return unavailable(); + openCircuit(circuit); + return unavailable(circuit); } - failures = 0; - outageUntil = 0; + circuit.failures = 0; + circuit.outageUntil = 0; return reply(200, { ok: true, png: `data:image/jpeg;base64,${image}`, @@ -233,7 +295,7 @@ export function createHandler({ } finally { clearTimeout(timeout); inFlight -= 1; - if (isProbe) probeInFlight = false; + if (isProbe) circuit.probeInFlight = false; } }; } diff --git a/system/tests/flux.test.mjs b/system/tests/flux.test.mjs index 7e406c79ff..2769271c21 100644 --- a/system/tests/flux.test.mjs +++ b/system/tests/flux.test.mjs @@ -292,3 +292,229 @@ test("Cloudflare free-quota exhaustion waits for midnight without more reservati assert.equal((await handler(event())).statusCode, 200); assert.equal(reservations, 2); }); + +const quota = () => + Response.json({ success: false, errors: [{ code: 3036 }] }, { status: 429 }); +const sanaImage = () => ({ + png: "data:image/jpeg;base64,sana-image", + width: 768, + height: 768, + seed: 17, +}); + +test("confirmed Cloudflare quota routes only a subsequent request to separately reserved Sana", async () => { + const reservations = [], + calls = []; + const handler = createHandler({ + env: { ...env, IMAGE_FAL_KEY: "synthetic-fal" }, + reserveBudget: async (request) => { + reservations.push(request); + return { allowed: true }; + }, + fetch: async () => { + calls.push("cloudflare"); + return quota(); + }, + generateSana: async ({ prompt, key, signal, fetchImpl }) => { + assert.equal(prompt, "a square"); + assert.equal(key, "synthetic-fal"); + assert.equal(signal.aborted, false); + assert.equal(typeof fetchImpl, "function"); + calls.push("fal-sana"); + return sanaImage(); + }, + }); + const first = await handler( + event({ prompt: "a square", preset: "raw", provider: "fal-sana" }), + ); + assert.equal(first.statusCode, 429); + assert.equal(first.headers["Retry-After"], "1"); + assert.equal(JSON.parse(first.body).reason, "provider_quota"); + assert.deepEqual( + calls, + ["cloudflare"], + "caller cannot select provider or trigger same-request failover", + ); + assert.deepEqual(reservations, [{ provider: "cloudflare" }]); + const second = await handler(event()); + assert.equal(second.statusCode, 200); + const image = JSON.parse(second.body); + assert.equal(image.provider, "fal-sana"); + assert.equal(image.width, 768); + assert.equal(image.height, 768); + assert.equal(image.seed, 17); + assert.deepEqual(calls, ["cloudflare", "fal-sana"]); + assert.deepEqual(reservations, [ + { provider: "cloudflare" }, + { provider: "fal-sana" }, + ]); +}); + +test("network errors and capacity429 never select Sana even with its key configured", async () => { + for (const failure of [ + () => { + throw new DOMException("timeout", "AbortError"); + }, + () => + Response.json( + { success: false, errors: [{ code: 3040 }] }, + { status: 429 }, + ), + ]) { + let sanaCalls = 0, + reservations = 0; + const handler = createHandler({ + env: { ...env, IMAGE_FAL_KEY: "synthetic-fal" }, + now: () => 1000, + reserveBudget: async ({ provider }) => { + assert.equal(provider, "cloudflare"); + reservations++; + return { allowed: true }; + }, + fetch: failure, + generateSana: async () => { + sanaCalls++; + return sanaImage(); + }, + }); + assert.equal((await handler(event())).statusCode, 503); + assert.equal((await handler(event())).statusCode, 503); + assert.equal(sanaCalls, 0); + assert.equal(reservations, 1); + } +}); + +test("shared budget denial prevents Sana and does not try another provider", async () => { + const providers = []; + let cloudflareCalls = 0, + sanaCalls = 0; + const handler = createHandler({ + env: { ...env, IMAGE_FAL_KEY: "synthetic-fal" }, + reserveBudget: async ({ provider }) => { + providers.push(provider); + return provider === "cloudflare" + ? { allowed: true } + : { allowed: false, retryAfterSeconds: 86400 }; + }, + fetch: async () => { + cloudflareCalls++; + return quota(); + }, + generateSana: async () => { + sanaCalls++; + return sanaImage(); + }, + }); + await handler(event()); + const denied = await handler(event()); + assert.equal(denied.statusCode, 429); + assert.equal(denied.headers["Retry-After"], "86400"); + assert.equal(JSON.parse(denied.body).reason, "image_budget_exhausted"); + assert.deepEqual(providers, ["cloudflare", "fal-sana"]); + assert.equal(sanaCalls, 0); + assert.equal(cloudflareCalls, 1); +}); + +test("Sana failure has its own circuit and does not block Cloudflare at midnight", async () => { + let now = Date.parse("2026-09-21T23:59:55Z"), + sanaCalls = 0, + cloudflareCalls = 0; + const providers = []; + const handler = createHandler({ + env: { ...env, IMAGE_FAL_KEY: "synthetic-fal" }, + now: () => now, + reserveBudget: async ({ provider }) => { + providers.push(provider); + return { allowed: true }; + }, + fetch: async () => (++cloudflareCalls === 1 ? quota() : success()), + generateSana: async () => { + sanaCalls++; + throw new DOMException("timeout", "AbortError"); + }, + }); + await handler(event()); + assert.equal((await handler(event())).headers["Retry-After"], "60"); + assert.equal((await handler(event())).statusCode, 503); + assert.equal( + sanaCalls, + 1, + "ambiguous Sana failure is not retried or switched", + ); + now += 5000; + const reset = await handler(event()); + assert.equal(reset.statusCode, 200); + assert.equal(JSON.parse(reset.body).provider, "cloudflare"); + assert.deepEqual(providers, ["cloudflare", "fal-sana", "cloudflare"]); +}); + +test("Sana filtering is explicit and does not open an availability circuit", async () => { + let calls = 0; + const handler = createHandler({ + env: { ...env, IMAGE_FAL_KEY: "synthetic-fal" }, + reserveBudget: allowed, + fetch: async () => quota(), + generateSana: async () => { + calls++; + if (calls === 1) + throw Object.assign(new Error("synthetic filter"), { + reason: "filtered", + }); + return sanaImage(); + }, + }); + await handler(event()); + const filtered = await handler(event()); + assert.equal(filtered.statusCode, 422); + assert.equal(JSON.parse(filtered.body).reason, "filtered"); + assert.equal((await handler(event())).statusCode, 200); + assert.equal(calls, 2); +}); + +test("in-flight bound also applies to Sana before additional reservations", async () => { + let reservations = 0; + const pending = []; + const handler = createHandler({ + env: { ...env, IMAGE_FAL_KEY: "synthetic-fal" }, + reserveBudget: async () => { + reservations++; + return { allowed: true }; + }, + fetch: async () => quota(), + generateSana: () => new Promise((resolve) => pending.push(resolve)), + }); + await handler(event()); + const first = handler(event()), + second = handler(event()); + await new Promise(setImmediate); + const busy = await handler(event()); + assert.equal(busy.statusCode, 429); + assert.equal(JSON.parse(busy.body).reason, "busy"); + assert.equal(reservations, 3, "quota request plus two Sana reservations"); + for (const resolve of pending) resolve(sanaImage()); + await Promise.all([first, second]); +}); + +test("unrelated FAL_KEY cannot enable image overflow", async () => { + let sanaCalls = 0, + reservations = 0; + const handler = createHandler({ + env: { ...env, FAL_KEY: "unrelated-easel-key" }, + now: () => Date.parse("2026-09-21T23:55:00Z"), + reserveBudget: async () => { + reservations++; + return { allowed: true }; + }, + fetch: async () => quota(), + generateSana: async () => { + sanaCalls++; + return sanaImage(); + }, + }); + const first = await handler(event()); + assert.equal(first.headers["Retry-After"], "300"); + assert.equal(JSON.parse(first.body).reason, "image_budget_exhausted"); + assert.equal((await handler(event())).statusCode, 429); + assert.equal(sanaCalls, 0); + assert.equal(reservations, 1); +}); diff --git a/system/tests/image-generation-budget.test.mjs b/system/tests/image-generation-budget.test.mjs index 2211b5a9b9..351cafd252 100644 --- a/system/tests/image-generation-budget.test.mjs +++ b/system/tests/image-generation-budget.test.mjs @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { createMongoImageBudget } from "../backend/image-generation-budget.mjs"; +import { createMongoImageBudget, reserveImageBudget } from "../backend/image-generation-budget.mjs"; const now = () => Date.parse("2026-09-21T12:00:00Z"); const noDatabase = new Proxy({}, { get() { throw new Error("Unexpected database access"); } }); @@ -38,3 +38,86 @@ test("exhausted reservations distinguish daily and monthly reset times", async ( assert.deepEqual(await reserve(), { allowed: false, retryAfterSeconds }); } }); + + +test("unknown providers fail before any database access", async () => { + const reserve = createMongoImageBudget(noDatabase, { now, env: {} }); + for (const provider of ["nvidia", "__proto__", "constructor", null, {}]) { + await assert.rejects(reserve({ provider }), /Unknown image generation provider/); + await assert.rejects(reserveImageBudget({ provider }), /Unknown image generation provider/); + } +}); + +// A small in-memory conditional ledger for mixed-provider behavior; production +// Mongo atomicity is independently exercised using a temporary collection. +function ledgerFixture(seed) { + const documents = new Map(seed ? [[seed._id, structuredClone(seed)]] : []); + return { + documents, + async updateOne({ _id }, { $setOnInsert }) { + if (!documents.has(_id)) documents.set(_id, { _id, ...structuredClone($setOnInsert) }); + }, + async findOneAndUpdate(query, { $inc, $set }) { + const document = documents.get(query._id); + const dayField = Object.keys($inc).find((field) => field.startsWith("days.")); + const day = dayField.slice(5); + const dailyLimit = query.$or[1][dayField].$lte; + if (document.reservedMicroUsd > query.reservedMicroUsd.$lte || + (document.days[day] ?? 0) > dailyLimit) return null; + document.reservedMicroUsd += $inc.reservedMicroUsd; + document.days[day] = (document.days[day] ?? 0) + $inc[dayField]; + document.attempts += $inc.attempts; + Object.assign(document, $set); + return structuredClone(document); + }, + async findOne({ _id }) { return documents.get(_id); }, + }; +} + +test("mixed providers share exact daily and monthly caps across recreated reservers", async () => { + let at = now(); + const collection = ledgerFixture(); + const options = { + now: () => at, + env: { IMAGE_DAILY_BUDGET_USD: "0.002268", IMAGE_MONTHLY_BUDGET_USD: "0.003268" }, + }; + const reserve = createMongoImageBudget(collection, options); + assert.deepEqual(await reserve(), { allowed: true }); // Default remains Cloudflare. + assert.deepEqual(await reserve({ provider: "fal-sana" }), { allowed: true }); + const restarted = createMongoImageBudget(collection, options); + assert.deepEqual(await restarted({ provider: "cloudflare" }), { allowed: true }); + assert.deepEqual(await restarted({ provider: "fal-sana" }), { + allowed: false, retryAfterSeconds: 43200, + }); + const document = collection.documents.get("cloudflare-flux:2026-09"); + assert.equal(collection.documents.size, 1); + assert.equal(document.reservedMicroUsd, 2268); + assert.equal(document.days["2026-09-21"], 2268); + assert.equal(document.attempts, 3); + at += 86400000; + assert.deepEqual(await restarted({ provider: "fal-sana" }), { allowed: true }); + assert.deepEqual(await restarted({ provider: "cloudflare" }), { + allowed: false, retryAfterSeconds: 734400, + }); + assert.equal(document.reservedMicroUsd, 3268); + assert.equal(document.days["2026-09-22"], 1000); + assert.equal(document.attempts, 4); +}); + +test("Sana reservations retain spend from the historical Cloudflare ledger", async () => { + const collection = ledgerFixture({ + _id: "cloudflare-flux:2026-09", reservedMicroUsd: 634, attempts: 1, + days: { "2026-09-21": 634 }, + }); + const reserve = createMongoImageBudget(collection, { + now, + env: { IMAGE_DAILY_BUDGET_USD: "0.001634", IMAGE_MONTHLY_BUDGET_USD: "0.001634" }, + }); + assert.deepEqual(await reserve({ provider: "fal-sana" }), { allowed: true }); + assert.equal((await reserve({ provider: "cloudflare" })).allowed, false); + assert.equal((await reserve({ provider: "fal-sana" })).allowed, false); + assert.equal(collection.documents.size, 1); + const document = collection.documents.get("cloudflare-flux:2026-09"); + assert.equal(document.reservedMicroUsd, 1634); + assert.equal(document.attempts, 2); +}); diff --git a/system/tests/sana-image.test.mjs b/system/tests/sana-image.test.mjs new file mode 100644 index 0000000000..8c34bad230 --- /dev/null +++ b/system/tests/sana-image.test.mjs @@ -0,0 +1,59 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { generateSana } from "../backend/sana-image.mjs"; + +const jpeg = "data:image/jpeg;base64,/9j/2Q=="; +const result = () => ({ images: [{ url: jpeg, width: 768, height: 768 }], seed: 7 }); +const request = { prompt: "synthetic fabric", key: "synthetic-key" }; + +test("Sana submits one bounded image with inline output and the caller deadline", async () => { + const signal = new AbortController().signal; + let calls = 0; + const image = await generateSana({ ...request, signal, fetchImpl: async (url, options) => { + calls++; + assert.equal(url, "https://fal.run/fal-ai/sana"); + assert.equal(options.redirect, "error"); + assert.equal(options.signal, signal); + assert.equal(options.headers.Authorization, "Key synthetic-key"); + const body = JSON.parse(options.body); + assert.deepEqual(body.image_size, { width: 768, height: 768 }); + assert.equal(body.num_images, 1); + assert.equal(body.num_inference_steps, 18); + assert.equal(body.sync_mode, true); + assert.equal(body.enable_safety_checker, true); + return Response.json(result()); + } }); + assert.equal(calls, 1); + assert.deepEqual(image, { png: jpeg, width: 768, height: 768, seed: 7 }); +}); + +test("HTTP rejection and ambiguous failures never submit again", async () => { + for (const fail of [() => new Response("quota", { status: 429, headers: { "Retry-After": "30" } }), () => { throw new Error("network failed"); }]) { + let calls = 0; + await assert.rejects(generateSana({ ...request, fetchImpl: async () => { calls++; return fail(); } })); + assert.equal(calls, 1); + } +}); + +test("external image URLs, wrong sizes, malformed bytes and filtered images are rejected", async () => { + const invalid = [null, {}, { ...result(), has_nsfw_concepts: [true] }, + { images: [{ url: "https://example.com/image", width: 768, height: 768 }] }, + { images: [{ url: jpeg, width: 2048, height: 2048 }] }, + { images: [{ url: "data:image/jpeg;base64,aGVsbG8=", width: 768, height: 768 }] }]; + for (const data of invalid) { + let calls = 0; + await assert.rejects(generateSana({ ...request, fetchImpl: async () => { calls++; return Response.json(data); } })); + assert.equal(calls, 1); + } +}); + +test("a stalled response body retains the request's abort signal", async () => { + const controller = new AbortController(); + const pending = generateSana({ ...request, signal: controller.signal, fetchImpl: async (_url, options) => ({ + ok: true, + json: () => new Promise((_resolve, reject) => options.signal.addEventListener("abort", () => reject(options.signal.reason), { once: true })), + }) }); + await new Promise(resolve => setImmediate(resolve)); + controller.abort(new Error("deadline")); + await assert.rejects(pending, /deadline/); +});