AI Image Generator — TypeScript source
Turn a text prompt into a 1024×1024 image with FLUX.1 [schnell] on Cloudflare Workers AI. No account, no API key — rate-limited for fair use. Your prompt goes to the model through our Worker; we store no prompts and no images.
This is the TypeScript implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Pure logic for the AI Image Generator (FEAT-068). Zero I/O — the island
// and the /api/image-gen route both depend on this module, so validation,
// gate math, response parsing, and URL state are all testable without a
// browser or a Worker. Mirrored in Go at cli/ai-image-generator (lock-step
// test vectors).
export const MODEL = '@cf/black-forest-labs/flux-1-schnell';
/** flux-1-schnell's design point. Model max is 8, but cost scales at
* 9.6 neurons/step, so v1 pins 4 → a flat 57.6 neurons per image. */
export const STEPS = 4;
/** Our UX clamp — deliberately below the model's 2048-char limit. */
export const PROMPT_MAX_CHARS = 600;
export const IP_HOUR_CAP = 10;
/** Empirical (2026-09-10, live usage.neurons): one 1024² image = 172.8
* neurons — 3× the docs-table estimate. 40/day × 172.8 = 6,912, inside
* the 10,000 free allocation with ~30% headroom. */
export const DAILY_CAP = 40;
export type PromptInvalidReason = 'empty' | 'tooLong' | 'controlChars';
export function validatePrompt(
raw: string
): { ok: true; prompt: string } | { ok: false; reason: PromptInvalidReason } {
// Normalize line endings first so pasted CRLF/CR text keeps its structure.
const prompt = raw.replace(/\r\n?/g, '\n').trim();
if (prompt.length === 0) return { ok: false, reason: 'empty' };
if (prompt.length > PROMPT_MAX_CHARS) return { ok: false, reason: 'tooLong' };
// Control characters except \n (multi-line prompts stay legal): C0 minus
// U+000A, DEL, and C1. A stray \r was already normalized above.
if (/[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/.test(prompt)) {
return { ok: false, reason: 'controlChars' };
}
return { ok: true, prompt };
}
export function buildModelParams(prompt: string): { prompt: string; steps: number } {
return { prompt, steps: STEPS };
}
function minutesToUtcMidnight(now: number): number {
const msLeft = Date.UTC(
new Date(now).getUTCFullYear(),
new Date(now).getUTCMonth(),
new Date(now).getUTCDate() + 1
) - now;
return Math.max(1, Math.ceil(msLeft / 60_000));
}
export type GateVerdict =
| { ok: true }
| { ok: false; error: 'RATE_LIMITED_IP' | 'RATE_LIMITED_DAILY'; retryAfterMinutes: number };
export function checkGate(ipCount: number, dailyCount: number, now: number = Date.now()): GateVerdict {
if (dailyCount >= DAILY_CAP) {
return { ok: false, error: 'RATE_LIMITED_DAILY', retryAfterMinutes: minutesToUtcMidnight(now) };
}
if (ipCount >= IP_HOUR_CAP) {
return { ok: false, error: 'RATE_LIMITED_IP', retryAfterMinutes: 60 };
}
return { ok: true };
}
export type GenError =
| 'BAD_REQUEST'
| 'RATE_LIMITED_IP'
| 'RATE_LIMITED_DAILY'
| 'MODEL_ERROR'
| 'MODEL_UNAVAILABLE'
| 'NETWORK';
export type GenerateResult =
| { ok: true; dataUrl: string }
| { ok: false; error: GenError; retryAfterMinutes?: number };
export function parseGenerateResponse(status: number, body: unknown): GenerateResult {
if (status === 400) return { ok: false, error: 'BAD_REQUEST' };
if (status === 429) {
const b = (body ?? {}) as { error?: unknown; retryAfterMinutes?: unknown };
const error =
b.error === 'RATE_LIMITED_DAILY' ? 'RATE_LIMITED_DAILY' : 'RATE_LIMITED_IP';
const retryAfterMinutes =
typeof b.retryAfterMinutes === 'number' ? b.retryAfterMinutes : 60;
return { ok: false, error, retryAfterMinutes };
}
if (status === 503) return { ok: false, error: 'MODEL_UNAVAILABLE' };
if (status === 502) return { ok: false, error: 'MODEL_ERROR' };
if (status === 200) {
const url = (body as { dataUrl?: unknown } | null)?.dataUrl;
if (typeof url === 'string' && url.startsWith('data:image/')) {
return { ok: true, dataUrl: url };
}
return { ok: false, error: 'MODEL_ERROR' };
}
return { ok: false, error: 'NETWORK' };
}
export function encodeUrlState(prompt: string): string {
return prompt ? `?prompt=${encodeURIComponent(prompt)}` : '';
}
export function decodeUrlState(search: string): string {
return new URLSearchParams(search).get('prompt') ?? '';
}
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →