From a9784b83cd87045e0d50416020dfe844d90a3e46 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Sun, 28 Jun 2026 02:08:22 +0000 Subject: [PATCH] feat(openai): simulate image generation endpoint (gpt-image / ChatGPT Images) Add POST /openai/v1/images/generations simulating the gpt-image family ("ChatGPT Images"). Each request returns a synthetic, watermarked PNG of the exact requested size that renders the prompt text and a "LLMSIM SIMULATED IMAGE" label, so generated bytes are unmistakably simulated. - Self-contained PNG synthesis (src/imagegen.rs): dependency-free indexed-PNG encoder (CRC32/Adler32/stored-DEFLATE), base64 encoder, and a 5x7 bitmap font for readable, deterministic placeholder images. - Image API types and streaming events (src/openai/images.rs): request/response shapes, usage accounting scaled by size/quality, and quality/size-anchored generation timing. - Streaming engine (src/image_stream.rs): emits image_generation.partial_image frames (progressively sharper previews) then image_generation.completed, evenly spaced across the simulated generation time. - Register gpt-image-1, gpt-image-1-mini, gpt-image-1.5 model profiles; track image_requests in stats. - Examples (Python + TypeScript), spec (specs/image-generation.md), docs, and integration tests covering non-streaming, n>1, defaults, streaming partials, and model listing. Wire both examples into CI. --- .github/workflows/ci.yml | 7 +- AGENTS.md | 6 +- README.md | 2 + docs/api.md | 76 ++++ examples/README.md | 32 ++ examples/images_client.py | 150 +++++++ examples/node/images_client.ts | 130 ++++++ specs/api-endpoints.md | 8 + specs/architecture.md | 19 +- specs/image-generation.md | 145 +++++++ src/cli/config.rs | 4 + src/cli/handlers.rs | 159 ++++++++ src/cli/mod.rs | 4 + src/image_stream.rs | 256 ++++++++++++ src/imagegen.rs | 697 +++++++++++++++++++++++++++++++++ src/lib.rs | 2 + src/openai/images.rs | 318 +++++++++++++++ src/openai/mod.rs | 1 + src/openai/models.rs | 36 ++ src/stats.rs | 11 + tests/images_test.rs | 166 ++++++++ tests/smoke_test.sh | 38 ++ 22 files changed, 2264 insertions(+), 3 deletions(-) create mode 100644 examples/images_client.py create mode 100644 examples/node/images_client.ts create mode 100644 specs/image-generation.md create mode 100644 src/image_stream.rs create mode 100644 src/imagegen.rs create mode 100644 src/openai/images.rs create mode 100644 tests/images_test.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 161a057..ee2aa65 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -239,6 +239,10 @@ jobs: run: npx tsx anthropic_client.ts working-directory: examples/node + - name: Run TypeScript image example + run: npx tsx images_client.ts + working-directory: examples/node + - name: Run Go Anthropic example run: go run anthropic_client.go working-directory: examples/go @@ -260,7 +264,8 @@ jobs: openresponses_client langchain_client \ langchain_responses_client openai_websocket_client \ pydantic_ai_client \ - anthropic_client anthropic_langchain) + anthropic_client anthropic_langchain \ + images_client) declare -A pid for ex in "${examples[@]}"; do uv run "examples/$ex.py" > "$RUNNER_TEMP/$ex.log" 2>&1 & diff --git a/AGENTS.md b/AGENTS.md index a8600a9..0ff5f53 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,7 @@ Available specs: - `specs/api-endpoints.md` - API endpoint structure and routing conventions - `specs/responses-api.md` - OpenAI Responses API simulation - `specs/anthropic-api.md` - Anthropic Messages API simulation +- `specs/image-generation.md` - OpenAI image generation (gpt-image / "ChatGPT Images") simulation - `specs/scripted-mode.md` - Multi-turn scripted responses for agent scenario tests - `specs/load-testing.md` - Load testing framework and benchmarks - `specs/throughput-benchmark.md` - Peak throughput (req/s) benchmark with parallelisation scaling @@ -99,6 +100,7 @@ Examples: **Current OpenAI endpoints:** - `POST /openai/v1/chat/completions` - Chat completions (streaming supported) - `POST /openai/v1/responses` - Responses API (streaming supported) +- `POST /openai/v1/images/generations` - Image generation (gpt-image, streaming supported) - `GET /openai/v1/models` - List available models - `GET /openai/v1/models/:id` - Get model details @@ -119,7 +121,9 @@ See `specs/api-endpoints.md` for the full specification. #### Code organization - `src/` - Main source code (library and binary) -- `src/openai/` - OpenAI API types and model profiles +- `src/openai/` - OpenAI API types and model profiles (includes `images.rs` for image generation) +- `src/imagegen.rs` - Self-contained placeholder PNG synthesis (encoder + bitmap font) +- `src/image_stream.rs` - Image generation streaming engine (partial images) - `src/openresponses/` - OpenResponses API types and streaming - `src/anthropic/` - Anthropic Messages API types, model profiles, and streaming - `src/cli/` - CLI server, configuration, and HTTP handlers diff --git a/README.md b/README.md index f272533..21095bd 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ LLMSim replicates realistic LLM API behavior without running actual models. It s - **Multi-Provider API Support** - OpenAI Chat Completions, [OpenResponses](https://www.openresponses.org), and Anthropic Messages APIs - **Realistic Latency Simulation** - Time-to-first-token (TTFT) and inter-token delays with normal distribution - **Streaming Support** - Server-Sent Events (SSE) for OpenAI, OpenResponses, and Anthropic streaming formats +- **Image Generation** - Simulated gpt-image ("ChatGPT Images") endpoint returning watermarked PNGs, with streaming partial images - **Accurate Token Counting** - Uses tiktoken-rs (OpenAI's tokenizer implementation) - **Error Injection** - Rate limits (429), server errors (500/503), timeouts - **Multiple Response Generators** - Lorem ipsum, echo, fixed, random, sequence @@ -131,6 +132,7 @@ Note that `count_tokens` and the `tokens` module are only available with the | `/openai/v1/models` | GET | List available models | | `/openai/v1/models/{model_id}` | GET | Get specific model details | | `/openai/v1/responses` | POST | Responses API (streaming & non-streaming) | +| `/openai/v1/images/generations` | POST | Image generation (gpt-image, streaming & non-streaming) | When using OpenAI SDKs, set the base URL to `http://localhost:8080/openai/v1`. diff --git a/docs/api.md b/docs/api.md index 81d6e1e..8b1c222 100644 --- a/docs/api.md +++ b/docs/api.md @@ -19,6 +19,7 @@ LLMSim provides multiple API providers with provider-namespaced routes. | `/openai/v1/chat/completions` | POST | Chat completions (streaming & non-streaming) | | `/openai/v1/responses` | POST | Responses API (streaming & non-streaming) | | `/openai/v1/responses` | WS | WebSocket mode for Responses API | +| `/openai/v1/images/generations` | POST | Image generation (streaming & non-streaming) | | `/openai/v1/models` | GET | List available models | | `/openai/v1/models/:id` | GET | Get model details | @@ -214,6 +215,81 @@ curl http://localhost:8080/openai/v1/models curl http://localhost:8080/openai/v1/models/gpt-5 ``` +### Image Generation + +Simulates the gpt-image family ("ChatGPT Images"). Returns a synthetic PNG of +the requested size that renders the prompt text and a "LLMSIM SIMULATED IMAGE" +watermark — no real model runs. Works with the official SDKs +(`client.images.generate(...)`). + +```bash +curl http://localhost:8080/openai/v1/images/generations \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gpt-image-1", + "prompt": "a cat riding a bicycle", + "size": "1024x1024", + "quality": "low" + }' +``` + +#### Request Fields + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `prompt` | string | (required) | Text description of the image | +| `model` | string | `gpt-image-1` | `gpt-image-1`, `gpt-image-1-mini`, `gpt-image-1.5` | +| `n` | integer | `1` | Number of images (1–10) | +| `size` | string | `1024x1024` | `1024x1024`, `1536x1024`, `1024x1536`, or `auto` | +| `quality` | string | `high` | `low`, `medium`, `high`, `auto` | +| `output_format` | string | `png` | `png`, `jpeg`, `webp` (echoed back) | +| `background` | string | `opaque` | `transparent`, `opaque`, `auto` | +| `stream` | boolean | `false` | Stream partial images via SSE | +| `partial_images` | integer | `0` | Progressive preview frames (0–3) | + +#### Response + +```json +{ + "created": 1750000000, + "data": [{ "b64_json": "" }], + "usage": { + "input_tokens": 8, + "output_tokens": 272, + "total_tokens": 280, + "input_tokens_details": { "text_tokens": 8, "image_tokens": 0 } + }, + "size": "1024x1024", + "quality": "low", + "output_format": "png", + "background": "opaque" +} +``` + +#### Streaming + +With `"stream": true` and `"partial_images": N`, the server emits SSE events: +`image_generation.partial_image` frames (progressively sharper previews) followed +by a final `image_generation.completed` event carrying the full image and usage. + +```bash +curl http://localhost:8080/openai/v1/images/generations \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gpt-image-1", + "prompt": "sunset over mountains", + "stream": true, + "partial_images": 3 + }' +``` + +Generation latency scales with quality and image size and is anchored to the +configured latency profile; the `instant`/`fast` profiles make it effectively +immediate for tests and load runs. + +See [`specs/image-generation.md`](../specs/image-generation.md) for the full +specification. + ## OpenResponses API (`/openresponses/v1/...`) [OpenResponses](https://www.openresponses.org) is an open-source specification for building multi-provider, interoperable LLM interfaces. diff --git a/examples/README.md b/examples/README.md index 7076bc8..8923dc5 100644 --- a/examples/README.md +++ b/examples/README.md @@ -72,6 +72,16 @@ Using the OpenResponses specification with httpx: uv run examples/openresponses_client.py ``` +### Image Generation + +Generate simulated images (gpt-image / "ChatGPT Images"), including streaming +with progressive partial images. Each image is a synthetic PNG of the requested +size rendering the prompt text and a "LLMSIM SIMULATED IMAGE" watermark: + +```bash +uv run examples/images_client.py +``` + ### WebSocket Mode Using the WebSocket transport for the Responses API: @@ -151,6 +161,15 @@ Direct usage of the official OpenAI Node.js library: npx tsx openai_client.ts ``` +### OpenAI Image Generation + +Generate simulated images (gpt-image / "ChatGPT Images"), including streaming +with progressive partial images: + +```bash +npx tsx images_client.ts +``` + ### Vercel AI SDK Using the [Vercel AI SDK](https://sdk.vercel.ai) via its OpenAI-compatible provider: @@ -242,6 +261,19 @@ curl http://localhost:8080/openai/v1/responses \ }' ``` +### OpenAI Image Generation + +```bash +curl http://localhost:8080/openai/v1/images/generations \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gpt-image-1", + "prompt": "a cat riding a bicycle", + "size": "1024x1024", + "quality": "low" + }' +``` + ### OpenResponses API ```bash diff --git a/examples/images_client.py b/examples/images_client.py new file mode 100644 index 0000000..865bc10 --- /dev/null +++ b/examples/images_client.py @@ -0,0 +1,150 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "openai>=2.44.0", +# "httpx>=0.27.0", +# ] +# /// +""" +Image generation example for llmsim (gpt-image / "ChatGPT Images"). + +This script demonstrates the simulated OpenAI image generation endpoint. The +server returns a synthetic PNG of the requested size that renders the prompt +text and a clear "LLMSIM SIMULATED IMAGE" watermark — no real model runs. + +Server endpoint: + POST /openai/v1/images/generations - Image generation (streaming supported) + +Prerequisites: + Start the llmsim server first: + llmsim serve --port 8080 + + Or from source: + cargo run --release -- serve --port 8080 + +Usage: + uv run examples/images_client.py + +Environment variables: + LLMSIM_URL: Server URL (default: http://localhost:8080/openai/v1) +""" + +import base64 +import json +import os +import sys +import tempfile + +import httpx +from openai import OpenAI + + +def main() -> None: + base_url = os.environ.get("LLMSIM_URL", "http://localhost:8080/openai/v1") + out_dir = tempfile.mkdtemp(prefix="llmsim-images-") + + print("=" * 50) + print("OpenAI Image Generation + LLMSim Example") + print("=" * 50) + print(f"\nConnecting to: {base_url}") + print(f"Saving images to: {out_dir}") + print() + + client = OpenAI(base_url=base_url, api_key="not-needed") + + # Example 1: Basic image generation (non-streaming). + print("1. Generate an image") + print("-" * 30) + try: + result = client.images.generate( + model="gpt-image-1", + prompt="a cat riding a bicycle on the moon", + size="1024x1024", + quality="low", + ) + except Exception as e: # noqa: BLE001 + print(f"Error: {e}") + print("\nMake sure the llmsim server is running:") + print(" llmsim serve --port 8080") + sys.exit(1) + + image = result.data[0] + png = base64.b64decode(image.b64_json) + path = os.path.join(out_dir, "moon_cat.png") + with open(path, "wb") as f: + f.write(png) + print(f"Saved {len(png):,} bytes -> {path}") + print(f"Usage: {result.usage}") + print() + + # Example 2: Different sizes and qualities. + print("2. Sizes and qualities") + print("-" * 30) + for size, quality in [("1024x1024", "low"), ("1536x1024", "medium"), ("1024x1536", "high")]: + result = client.images.generate( + model="gpt-image-1", + prompt=f"a {quality} quality landscape, {size}", + size=size, + quality=quality, + ) + png = base64.b64decode(result.data[0].b64_json) + print(f" {size} {quality:>6}: {len(png):,} bytes, output_tokens={result.usage.output_tokens}") + print() + + # Example 3: Streaming with partial images. + # + # The OpenAI image streaming API emits `image_generation.partial_image` + # events (progressively sharper previews) followed by a final + # `image_generation.completed` event carrying the full image and usage. + # We parse the SSE stream directly with httpx so the event shape is explicit. + print("3. Streaming with partial images") + print("-" * 30) + with httpx.Client(base_url=base_url, timeout=120.0) as http: + with http.stream( + "POST", + "/images/generations", + json={ + "model": "gpt-image-1", + "prompt": "sunset over snowy mountains", + "size": "1024x1024", + "quality": "medium", + "stream": True, + "partial_images": 3, + }, + ) as resp: + buf = "" + for chunk in resp.iter_text(): + buf += chunk + while "\n\n" in buf: + raw, buf = buf.split("\n\n", 1) + data_line = next( + (ln[6:] for ln in raw.splitlines() if ln.startswith("data: ")), + None, + ) + if not data_line: + continue + event = json.loads(data_line) + etype = event["type"] + png = base64.b64decode(event["b64_json"]) + if etype == "image_generation.partial_image": + idx = event["partial_image_index"] + path = os.path.join(out_dir, f"stream_partial_{idx}.png") + with open(path, "wb") as f: + f.write(png) + print(f" partial #{idx}: {len(png):,} bytes -> {path}") + elif etype == "image_generation.completed": + path = os.path.join(out_dir, "stream_final.png") + with open(path, "wb") as f: + f.write(png) + print(f" completed : {len(png):,} bytes -> {path}") + print(f" usage : {event['usage']}") + print() + + print("=" * 50) + print("Examples complete!") + print("=" * 50) + + +if __name__ == "__main__": + main() diff --git a/examples/node/images_client.ts b/examples/node/images_client.ts new file mode 100644 index 0000000..7d9e2ed --- /dev/null +++ b/examples/node/images_client.ts @@ -0,0 +1,130 @@ +#!/usr/bin/env npx tsx +/** + * Image generation example for llmsim (TypeScript/JavaScript). + * + * Demonstrates the simulated OpenAI image generation endpoint. The server + * returns a synthetic PNG of the requested size that renders the prompt text + * and a clear "LLMSIM SIMULATED IMAGE" watermark — no real model runs. + * + * Server endpoint: + * POST /openai/v1/images/generations - Image generation (streaming supported) + * + * Prerequisites: + * Start the llmsim server first: + * llmsim serve --port 8080 + * + * Install dependencies: + * npm install + * + * Usage: + * npx tsx images_client.ts + * + * Environment variables: + * LLMSIM_URL: Server URL (default: http://localhost:8080/openai/v1) + */ + +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import OpenAI from "openai"; + +async function main(): Promise { + const baseURL = process.env.LLMSIM_URL || "http://localhost:8080/openai/v1"; + const outDir = mkdtempSync(join(tmpdir(), "llmsim-images-")); + + console.log("=".repeat(50)); + console.log("OpenAI Image Generation (TypeScript) + LLMSim"); + console.log("=".repeat(50)); + console.log(`\nConnecting to: ${baseURL}`); + console.log(`Saving images to: ${outDir}\n`); + + const client = new OpenAI({ baseURL, apiKey: "not-needed" }); + + // Example 1: Basic image generation (non-streaming). + console.log("1. Generate an image"); + console.log("-".repeat(30)); + let result; + try { + result = await client.images.generate({ + model: "gpt-image-1", + prompt: "a cat riding a bicycle on the moon", + size: "1024x1024", + quality: "low", + }); + } catch (e) { + console.error(`Error: ${e}`); + console.error("\nMake sure the llmsim server is running:"); + console.error(" llmsim serve --port 8080"); + process.exit(1); + } + + const b64 = result.data?.[0]?.b64_json ?? ""; + const png = Buffer.from(b64, "base64"); + const path = join(outDir, "moon_cat.png"); + writeFileSync(path, png); + console.log(`Saved ${png.length.toLocaleString()} bytes -> ${path}`); + console.log(`Usage:`, result.usage); + console.log(); + + // Example 2: Streaming with partial images. + // + // The image streaming API emits `image_generation.partial_image` events + // (progressively sharper previews) then a final `image_generation.completed` + // event with the full image and usage. We parse the SSE stream with fetch. + console.log("2. Streaming with partial images"); + console.log("-".repeat(30)); + const resp = await fetch(`${baseURL}/images/generations`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "gpt-image-1", + prompt: "sunset over snowy mountains", + size: "1024x1024", + quality: "medium", + stream: true, + partial_images: 3, + }), + }); + + const reader = resp.body!.getReader(); + const decoder = new TextDecoder(); + let buf = ""; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + let sep: number; + while ((sep = buf.indexOf("\n\n")) !== -1) { + const raw = buf.slice(0, sep); + buf = buf.slice(sep + 2); + const dataLine = raw + .split("\n") + .find((ln) => ln.startsWith("data: ")); + if (!dataLine) continue; + const event = JSON.parse(dataLine.slice(6)); + const data = Buffer.from(event.b64_json, "base64"); + if (event.type === "image_generation.partial_image") { + const p = join(outDir, `stream_partial_${event.partial_image_index}.png`); + writeFileSync(p, data); + console.log( + ` partial #${event.partial_image_index}: ${data.length.toLocaleString()} bytes -> ${p}`, + ); + } else if (event.type === "image_generation.completed") { + const p = join(outDir, "stream_final.png"); + writeFileSync(p, data); + console.log(` completed : ${data.length.toLocaleString()} bytes -> ${p}`); + console.log(` usage :`, event.usage); + } + } + } + console.log(); + + console.log("=".repeat(50)); + console.log("Examples complete!"); + console.log("=".repeat(50)); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/specs/api-endpoints.md b/specs/api-endpoints.md index a2c3f64..9328052 100644 --- a/specs/api-endpoints.md +++ b/specs/api-endpoints.md @@ -20,6 +20,7 @@ This specification defines the URL structure and routing conventions for LLMSim | OpenAI | `/openai` | `/v1/chat/completions` | `/openai/v1/chat/completions` | | OpenAI | `/openai` | `/v1/responses` | `/openai/v1/responses` | | OpenAI | `/openai` | `/v1/models` | `/openai/v1/models` | +| OpenAI | `/openai` | `/v1/images/generations` | `/openai/v1/images/generations` | | OpenResponses | `/openresponses` | `/v1/responses` | `/openresponses/v1/responses` | | Anthropic | `/anthropic` | `/v1/messages` | `/anthropic/v1/messages` | @@ -32,6 +33,7 @@ This specification defines the URL structure and routing conventions for LLMSim | `POST` | `/openai/v1/chat/completions` | Chat Completions API | | `POST` | `/openai/v1/responses` | Responses API | | `GET/WS` | `/openai/v1/responses` | WebSocket mode for Responses API | +| `POST` | `/openai/v1/images/generations` | Image generation API (streaming supported) | | `GET` | `/openai/v1/models` | List available models | | `GET` | `/openai/v1/models/:model_id` | Get model details | @@ -46,6 +48,12 @@ The content-array form is accepted for every model. Image parts are gated on the Image parts contribute an approximate token cost to `usage` (`prompt_tokens` / Responses `input_tokens`) but do not influence the generated output text. Because the simulator never fetches or decodes image bytes, the per-image cost is approximated from the `detail` hint: `"low"` → 85 tokens, otherwise (`"high"`/`"auto"`/unset) → 765 tokens (a representative ~1024×1024 image under OpenAI's tile formula). The Responses `input_image` part carries no `detail` and is charged the high-detail default. This applies to `/openai/v1/chat/completions`, `/openai/v1/responses`, and `/openresponses/v1/responses`. +**R2.6**: The `/openai/v1/images/generations` endpoint simulates the gpt-image +family ("ChatGPT Images"), returning a synthetic watermarked PNG of the +requested size. It supports both non-streaming JSON and SSE streaming with +progressive partial images. See `specs/image-generation.md` for the full +specification. + **R2.4**: The `/openai/v1/responses` endpoint supports WebSocket upgrade for persistent connections. When a WebSocket upgrade is requested, the endpoint switches to WebSocket mode where clients send `response.create` events and receive the same streaming events as the SSE format, but as JSON text frames without the SSE envelope. **R2.3**: The models endpoint (`/openai/v1/models`) returns extended model information sourced from [models.dev](https://models.dev): diff --git a/specs/architecture.md b/specs/architecture.md index d6a2d00..ab6c8c7 100644 --- a/specs/architecture.md +++ b/specs/architecture.md @@ -26,6 +26,7 @@ llmsim/ │ │ ├── types.rs │ │ ├── models.rs # Model profiles from models.dev │ │ ├── responses.rs # Responses API types +│ │ ├── images.rs # Image generation (gpt-image) types + streaming events │ │ └── websocket.rs # WebSocket (Realtime) types │ ├── openresponses/ # OpenResponses API types (https://www.openresponses.org) │ │ ├── mod.rs @@ -42,6 +43,8 @@ llmsim/ │ ├── generator.rs # Response generators │ ├── stream.rs # SSE streaming engine │ ├── responses_stream.rs # Responses API streaming +│ ├── imagegen.rs # Self-contained placeholder PNG synthesis (encoder + font) +│ ├── image_stream.rs # Image generation streaming engine (partial images) │ └── errors.rs # Error injection ├── benchmarks/ # Load testing benchmarks (k6) │ ├── run-benchmark.sh # Main benchmark runner @@ -182,6 +185,13 @@ See `specs/api-endpoints.md` for the full specification. See `specs/responses-api.md` for detailed Responses API specification. +### OpenAI Image Generation API +- `POST /openai/v1/images/generations` - Generate images (streaming and non-streaming) + +Simulates the gpt-image family ("ChatGPT Images"), returning a synthetic +watermarked PNG of the requested size. See `specs/image-generation.md` for the +detailed specification. + ### Anthropic Messages API - `POST /anthropic/v1/messages` - Create a message (streaming and non-streaming) - `GET /anthropic/v1/models` - List available Claude models @@ -191,7 +201,7 @@ See `specs/anthropic-api.md` for detailed Anthropic API specification. ### Module Organization -- **Public modules** (`openai`, `openresponses`, `anthropic`, `generator`, `latency`, `stream`, `responses_stream`, `errors`, `stats`): Core library functionality, re-exported from `lib.rs`, always available +- **Public modules** (`openai`, `openresponses`, `anthropic`, `generator`, `latency`, `stream`, `responses_stream`, `imagegen`, `image_stream`, `errors`, `stats`): Core library functionality, re-exported from `lib.rs`, always available - **Token module** (`tokens`): Token counting behind the `tokens` feature (tiktoken-rs) - **CLI modules** (`cli/*`): Server-specific code, HTTP handlers and configuration, behind the `server` feature - **TUI modules** (`tui/*`): Optional terminal dashboard behind the `tui` feature, built with Ratatui @@ -315,6 +325,13 @@ The `/openai/v1/models` endpoint returns realistic model data including context | gpt-4.1-mini | 1M | 32K | Vision, Tools, JSON | | gpt-4.1-nano | 1M | 32K | Vision, Tools, JSON | +### Image Generation Models (gpt-image / "ChatGPT Images") +| Model | Context | Max Output | Capabilities | +|-------|---------|------------|--------------| +| gpt-image-1 | 32K | 4160 img tokens | Vision (image in/out) | +| gpt-image-1-mini | 32K | 4160 img tokens | Vision (image in/out) | +| gpt-image-1.5 | 32K | 4160 img tokens | Vision (image in/out) | + ### Claude Family | Model | Context | Max Output | Capabilities | |-------|---------|------------|--------------| diff --git a/specs/image-generation.md b/specs/image-generation.md new file mode 100644 index 0000000..16fe7d0 --- /dev/null +++ b/specs/image-generation.md @@ -0,0 +1,145 @@ +# OpenAI Image Generation API Specification + +## Abstract + +This specification defines the simulated OpenAI image generation endpoint for +LLMSim, replicating the traffic shape of the gpt-image family (the "ChatGPT +Images" capability) without running a real diffusion model. The endpoint +produces a synthetic but valid PNG of the exact requested size that renders the +request prompt and a clear "LLMSIM SIMULATED IMAGE" watermark, so generated +bytes are unambiguously identifiable as simulated. Both the non-streaming JSON +response and the SSE streaming flow (with progressive partial images) are +supported, along with realistic, quality- and size-scaled latency. + +## Requirements + +### R1: Core Endpoint + +**R1.1**: Implement `POST /openai/v1/images/generations` accepting the OpenAI +image generation request body and returning a simulated image response. + +**R1.2**: The endpoint MUST be compatible with the official OpenAI SDKs when +configured with `{base_url}/openai/v1` as the base URL (e.g. +`client.images.generate(...)`). + +### R2: Request Parameters + +**R2.1**: Accept the following request fields: + +| Field | Type | Default | Notes | +|-------|------|---------|-------| +| `prompt` | string | (required) | Text description of the image | +| `model` | string | `gpt-image-1` | Image model id | +| `n` | integer | `1` | Number of images, clamped to 1–10 | +| `size` | string | `1024x1024` | `WxH`, or `auto` (resolved to default) | +| `quality` | string | `high` | `low` \| `medium` \| `high` \| `auto` | +| `output_format` | string | `png` | `png` \| `jpeg` \| `webp` (echoed back) | +| `background` | string | `opaque` | `transparent` \| `opaque` \| `auto` | +| `response_format` | string | — | Accepted for DALL·E compatibility | +| `stream` | boolean | `false` | Stream partial images via SSE | +| `partial_images` | integer | `0` | Progressive previews, clamped to 0–3 | +| `moderation` | string | — | Accepted and ignored | +| `user` | string | — | Accepted and ignored | + +**R2.2**: Supported sizes mirror the gpt-image models: `1024x1024`, +`1536x1024`, `1024x1536`. Arbitrary `WxH` values are accepted and clamped to +the 16–4096 px range per axis; unparseable sizes fall back to `1024x1024`. + +### R3: Non-Streaming Response + +**R3.1**: Return a JSON object with: +- `created`: Unix timestamp +- `data`: array of `n` objects, each with `b64_json` (base64 PNG) +- `usage`: token usage (see R5) +- `size`, `quality`, `output_format`, `background`: the resolved parameters + +**R3.2**: Each `data[i].b64_json` MUST decode to a valid PNG of the requested +size. + +### R4: Streaming Response + +**R4.1**: When `stream` is `true`, respond with `text/event-stream` emitting: +- `partial_images` events of type `image_generation.partial_image`, each with a + `partial_image_index` (0-based) and a `b64_json` preview frame +- a final `image_generation.completed` event with the full image and `usage` + +**R4.2**: Partial frames MUST be progressively refined: earlier partials are +coarser (pixelated previews), converging to a crisp final frame. + +**R4.3**: Each streamed event payload includes `type`, `b64_json`, `created_at`, +`size`, `quality`, `background`, and `output_format`. Partial events also +include `partial_image_index`; the completed event also includes `usage`. + +**R4.4**: Events are framed as SSE: `event: \ndata: \n\n`. + +### R5: Usage Accounting + +**R5.1**: `usage` reports: +- `input_tokens`: token count of the prompt text +- `output_tokens`: estimated image tokens, scaled by size and quality +- `total_tokens`: sum of the above +- `input_tokens_details`: `{ text_tokens, image_tokens }` (image_tokens is 0 for + text-only prompts) + +**R5.2**: Output image token baselines are anchored to gpt-image-1 at +1024x1024 (low ≈ 272, medium ≈ 1056, high ≈ 4160) and scale linearly with pixel +area. + +### R6: Latency + +**R6.1**: Generation time is simulated and scales with quality and image area. +It is anchored to the configured latency profile: model-derived latency (the +default) yields realistic multi-second waits, while the `instant` and `fast` +profiles collapse the wait so tests and load runs stay fast. + +**R6.2**: For streaming, partial and completed frames are spaced evenly across +the total generation time. + +### R7: Models + +**R7.1**: Register the gpt-image model family in the model registry and default +model list so they appear in `GET /openai/v1/models`: +`gpt-image-1`, `gpt-image-1-mini`, `gpt-image-1.5`. + +**R7.2**: Image models advertise `vision` capability (they accept image inputs) +and do not advertise text tool-calling, JSON mode, or reasoning. + +### R8: Error Injection + +**R8.1**: The endpoint participates in the shared error-injection model +(rate-limit and server errors) like the other OpenAI endpoints. + +### R9: Statistics + +**R9.1**: Image requests are counted under a dedicated `image_requests` stat and +contribute to total/streaming/non-streaming counters, token totals, and latency +tracking exposed by `GET /llmsim/stats`. + +## Non-Requirements + +- Real image synthesis or any resemblance between the prompt and the rendered + content beyond the literal prompt text drawn onto the placeholder. +- Image edits (`/v1/images/edits`) and variations (`/v1/images/variations`). + These may be added later following the same pattern. +- The Responses API `image_generation` tool. The dedicated images endpoint is + the primary, SDK-compatible surface; tool-based generation is a future + extension. +- Honoring `output_format`/`background` in the actual encoded bytes (the + simulator always emits an indexed PNG); these parameters are echoed back for + client compatibility. + +## Rationale + +### Why a self-contained PNG encoder? + +LLMSim avoids non-permissive and heavyweight dependencies. The endpoint ships a +tiny dependency-free indexed-PNG encoder and 5x7 bitmap font, producing a valid, +readable, watermarked image. Uncompressed ("stored") DEFLATE blocks keep the +encoder trivial; combined with an 8-bit palette this yields realistic payload +sizes (~1 MB for 1024x1024) without a compression crate. + +### Why progressive partial images? + +Real gpt-image streaming returns increasingly refined previews. Simulating this +with decreasing pixelation lets clients exercise the same partial-image handling +code paths they would use against the real API. diff --git a/src/cli/config.rs b/src/cli/config.rs index 8a44035..addc687 100644 --- a/src/cli/config.rs +++ b/src/cli/config.rs @@ -237,6 +237,10 @@ fn default_models() -> Vec { "gpt-4.1".to_string(), "gpt-4.1-mini".to_string(), "gpt-4.1-nano".to_string(), + // OpenAI image generation models (gpt-image / "ChatGPT Images") + "gpt-image-1".to_string(), + "gpt-image-1-mini".to_string(), + "gpt-image-1.5".to_string(), // Claude family "claude-3.5-sonnet".to_string(), "claude-3.7-sonnet".to_string(), diff --git a/src/cli/handlers.rs b/src/cli/handlers.rs index 17b75c7..db984db 100644 --- a/src/cli/handlers.rs +++ b/src/cli/handlers.rs @@ -5,7 +5,13 @@ use super::state::AppState; use crate::ids::{prefixed_id, unix_timestamp}; use crate::{ create_generator, + image_stream::ImageStream, + imagegen::{base64_encode, render_png, PlaceholderSpec}, openai::{ + images::{ + estimate_image_tokens, image_total_duration, ImageData, ImageGenerationRequest, + ImageGenerationResponse, ImageInputTokensDetails, ImagesUsage, + }, ChatCompletionRequest, ChatCompletionResponse, ErrorResponse, InputItem, InputRole, MessageContent, Model, ModelsResponse, OutputContentPart, OutputItem, OutputRole, OutputTokensDetails, ReasoningConfig, ResponseStatus, ResponsesErrorResponse, @@ -932,6 +938,159 @@ pub async fn create_response( } } +/// POST /openai/v1/images/generations +/// +/// Simulates the OpenAI image generation API (the gpt-image / "ChatGPT Images" +/// capability). Returns a synthetic PNG of the requested size that renders the +/// prompt text and a clear "LLMSIM SIMULATED IMAGE" watermark. Supports both +/// the non-streaming JSON response and SSE streaming with partial images. +pub async fn create_image( + State(state): State>, + Json(request): Json, +) -> Result { + let request_start = Instant::now(); + + tracing::info!( + model = %request.model, + stream = request.stream, + "Image generation request" + ); + + state + .stats + .record_request_start(&request.model, request.stream, EndpointType::Images); + + // Check for error injection (shares the configured error model). + let error_injector = ErrorInjector::new(state.config.error_config()); + if let Some(error) = error_injector.maybe_inject() { + tracing::warn!("Injecting error: {:?}", error); + + let status_code = error.status_code(); + let status = match status_code { + 429 => StatusCode::TOO_MANY_REQUESTS, + 500 => StatusCode::INTERNAL_SERVER_ERROR, + 503 => StatusCode::SERVICE_UNAVAILABLE, + 504 => StatusCode::GATEWAY_TIMEOUT, + 400 => StatusCode::BAD_REQUEST, + 401 => StatusCode::UNAUTHORIZED, + _ => StatusCode::INTERNAL_SERVER_ERROR, + }; + + state.stats.record_error(status_code); + + let mut response = Json(error.to_error_response()).into_response(); + *response.status_mut() = status; + if let Some(retry_after) = error.retry_after() { + response.headers_mut().insert( + header::RETRY_AFTER, + retry_after.to_string().parse().unwrap(), + ); + } + return Ok(response); + } + + // Image-generation timing is anchored to the configured latency profile; + // model-derived latency (the default) yields realistic multi-second waits, + // while `instant`/`fast` profiles collapse it for tests and load runs. + let latency = + if state.config.latency.profile.is_some() || state.config.latency.ttft_mean_ms.is_some() { + state.config.latency_profile() + } else { + LatencyProfile::from_model(&request.model) + }; + + let params = request.resolve(); + + // Usage: text tokens from the prompt + estimated image (output) tokens. + let text_tokens = crate::count_tokens_default(&request.prompt) + .unwrap_or(request.prompt.split_whitespace().count()) as u32; + let image_tokens = + estimate_image_tokens(params.width, params.height, ¶ms.quality) * params.n; + let usage = ImagesUsage { + input_tokens: text_tokens, + output_tokens: image_tokens, + total_tokens: text_tokens + image_tokens, + input_tokens_details: ImageInputTokensDetails { + text_tokens, + image_tokens: 0, + }, + }; + + if request.stream { + let stats = state.stats.clone(); + let input_tok = usage.input_tokens; + let output_tok = usage.output_tokens; + + let stream = ImageStream::new( + &request.model, + &request.prompt, + params, + latency, + usage.clone(), + ) + .with_on_complete(move || { + stats.record_request_end(request_start.elapsed(), input_tok, output_tok); + }); + + let body = Body::from_stream(stream.into_stream().map(Ok::<_, std::io::Error>)); + + Ok(Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/event-stream") + .header(header::CACHE_CONTROL, "no-cache") + .header(header::CONNECTION, "keep-alive") + .body(body) + .unwrap()) + } else { + // Non-streaming: simulate the full generation time, then return all + // images at once. + let total = crate::image_stream::capped(image_total_duration( + &latency, + ¶ms.quality, + params.width, + params.height, + params.n, + )); + if !total.is_zero() { + tokio::time::sleep(total).await; + } + + let mut data = Vec::with_capacity(params.n as usize); + for _ in 0..params.n { + let png = render_png(&PlaceholderSpec { + width: params.width, + height: params.height, + prompt: &request.prompt, + model: &request.model, + quality: ¶ms.quality, + blockiness: 1, + }); + data.push(ImageData { + b64_json: Some(base64_encode(&png)), + url: None, + revised_prompt: None, + }); + } + + state.stats.record_request_end( + request_start.elapsed(), + usage.input_tokens, + usage.output_tokens, + ); + + let response = ImageGenerationResponse { + created: unix_timestamp(), + data, + usage, + size: params.size, + quality: params.quality, + output_format: params.output_format, + background: params.background, + }; + Ok(Json(response).into_response()) + } +} + /// Extract text content from ResponsesInput for processing fn extract_input_text(input: &ResponsesInput, instructions: &Option) -> String { let mut parts = Vec::new(); diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 883dad3..afe4b90 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -39,6 +39,10 @@ pub fn build_router(state: Arc) -> Router { "/openai/v1/responses", post(handlers::create_response).get(ws_handler::ws_responses), ) + .route( + "/openai/v1/images/generations", + post(handlers::create_image), + ) // OpenResponses API routes (https://www.openresponses.org) .route( "/openresponses/v1/responses", diff --git a/src/image_stream.rs b/src/image_stream.rs new file mode 100644 index 0000000..ccc7f3f --- /dev/null +++ b/src/image_stream.rs @@ -0,0 +1,256 @@ +// Streaming engine for the simulated image-generation endpoint. +// +// Emits OpenAI-compatible `image_generation.partial_image` events followed by a +// final `image_generation.completed` event, with progressively sharper preview +// frames and realistic inter-frame timing. + +use crate::ids::unix_timestamp; +use crate::imagegen::{base64_encode, render_png, PlaceholderSpec}; +use crate::latency::LatencyProfile; +use crate::openai::images::{ + completed_image_sse, image_total_duration, partial_image_sse, CompletedImageEvent, ImagesUsage, + PartialImageEvent, ResolvedImageParams, +}; +use async_stream::stream; +use futures_core::Stream; +use std::pin::Pin; +use std::time::Duration; +use tokio::time::sleep; + +type OnCompleteCallback = Box; + +/// Ensures the completion callback runs exactly once, even on early drop. +struct CompletionGuard { + callback: Option, +} + +impl CompletionGuard { + fn new(callback: Option) -> Self { + Self { callback } + } + fn complete(&mut self) { + if let Some(cb) = self.callback.take() { + cb(); + } + } +} + +impl Drop for CompletionGuard { + fn drop(&mut self) { + self.complete(); + } +} + +/// A streaming simulated image generation. +pub struct ImageStream { + model: String, + prompt: String, + params: ResolvedImageParams, + latency: LatencyProfile, + usage: ImagesUsage, + on_complete: Option, +} + +impl ImageStream { + pub fn new( + model: impl Into, + prompt: impl Into, + params: ResolvedImageParams, + latency: LatencyProfile, + usage: ImagesUsage, + ) -> Self { + Self { + model: model.into(), + prompt: prompt.into(), + params, + latency, + usage, + on_complete: None, + } + } + + pub fn with_on_complete(mut self, callback: F) -> Self + where + F: FnOnce() + Send + 'static, + { + self.on_complete = Some(Box::new(callback)); + self + } + + pub fn into_stream(self) -> Pin + Send>> { + let ImageStream { + model, + prompt, + params, + latency, + usage, + on_complete, + } = self; + + Box::pin(stream! { + let mut guard = CompletionGuard::new(on_complete); + + let total = image_total_duration( + &latency, + ¶ms.quality, + params.width, + params.height, + params.n, + ); + + // p partials + 1 completed frame, evenly spaced across `total`. + let partials = params.partial_images; + let frames = partials + 1; + let per_frame = if frames > 0 { + total / frames + } else { + total + }; + + // Partial frames get progressively finer (smaller block size). + for i in 0..partials { + if !per_frame.is_zero() { + sleep(per_frame).await; + } + let blockiness = 1u32 << (partials - i); // e.g. 8,4,2 for p=3 + let png = render_png(&PlaceholderSpec { + width: params.width, + height: params.height, + prompt: &prompt, + model: &model, + quality: ¶ms.quality, + blockiness, + }); + let event = PartialImageEvent { + event_type: "image_generation.partial_image".to_string(), + b64_json: base64_encode(&png), + created_at: unix_timestamp(), + size: params.size.clone(), + quality: params.quality.clone(), + background: params.background.clone(), + output_format: params.output_format.clone(), + partial_image_index: i, + }; + yield partial_image_sse(&event); + } + + // Final, crisp frame. + if !per_frame.is_zero() { + sleep(per_frame).await; + } + let png = render_png(&PlaceholderSpec { + width: params.width, + height: params.height, + prompt: &prompt, + model: &model, + quality: ¶ms.quality, + blockiness: 1, + }); + let completed = CompletedImageEvent { + event_type: "image_generation.completed".to_string(), + b64_json: base64_encode(&png), + created_at: unix_timestamp(), + size: params.size.clone(), + quality: params.quality.clone(), + background: params.background.clone(), + output_format: params.output_format.clone(), + usage, + }; + yield completed_image_sse(&completed); + + guard.complete(); + }) + } +} + +/// Convenience: a near-instant TTFT delay used by the non-streaming handler so +/// it can `sleep` a single bounded duration without monopolising the runtime. +pub fn capped(total: Duration) -> Duration { + // Guard against an absurd configured profile producing minutes-long waits. + total.min(Duration::from_secs(120)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openai::images::ImageInputTokensDetails; + use futures_util::StreamExt; + + fn usage() -> ImagesUsage { + ImagesUsage { + total_tokens: 100, + input_tokens: 10, + output_tokens: 90, + input_tokens_details: ImageInputTokensDetails { + text_tokens: 10, + image_tokens: 0, + }, + } + } + + fn params(partials: u32) -> ResolvedImageParams { + ResolvedImageParams { + width: 256, + height: 256, + size: "256x256".to_string(), + quality: "low".to_string(), + output_format: "png".to_string(), + background: "opaque".to_string(), + n: 1, + partial_images: partials, + } + } + + #[tokio::test] + async fn streams_partials_then_completed() { + let stream = ImageStream::new( + "gpt-image-1", + "a red apple", + params(2), + LatencyProfile::instant(), + usage(), + ); + let events: Vec = stream.into_stream().collect().await; + + let partial = events + .iter() + .filter(|e| e.contains("image_generation.partial_image")) + .count(); + assert_eq!(partial, 2); + assert!(events + .last() + .unwrap() + .contains("image_generation.completed")); + // Completed event carries usage. + assert!(events.last().unwrap().contains("\"usage\"")); + } + + #[tokio::test] + async fn zero_partials_emits_only_completed() { + let stream = ImageStream::new( + "gpt-image-1", + "a blue car", + params(0), + LatencyProfile::instant(), + usage(), + ); + let events: Vec = stream.into_stream().collect().await; + assert_eq!(events.len(), 1); + assert!(events[0].contains("image_generation.completed")); + } + + #[tokio::test] + async fn partial_indices_increase() { + let stream = ImageStream::new( + "gpt-image-1", + "a tree", + params(3), + LatencyProfile::instant(), + usage(), + ); + let events: Vec = stream.into_stream().collect().await; + assert!(events[0].contains("\"partial_image_index\":0")); + assert!(events[1].contains("\"partial_image_index\":1")); + assert!(events[2].contains("\"partial_image_index\":2")); + } +} diff --git a/src/imagegen.rs b/src/imagegen.rs new file mode 100644 index 0000000..7edbc9b --- /dev/null +++ b/src/imagegen.rs @@ -0,0 +1,697 @@ +// Placeholder image synthesis for the simulated image-generation endpoint. +// +// Design decisions: +// - LLMSim does not run a real diffusion model. To keep the simulator +// dependency-free (no `image`/`png`/`flate2` crates) we ship a tiny +// self-contained PNG encoder plus a 5x7 bitmap font. The generated image is +// an *indexed-color* (color type 3) PNG of the exact requested size that +// draws the request prompt and a clear "LLMSIM SIMULATED IMAGE" watermark, +// so anyone inspecting the bytes immediately understands it is synthetic. +// - We deliberately use uncompressed ("stored") DEFLATE blocks. Combined with +// an 8-bit palette this keeps each 1024x1024 frame around ~1MB raw / ~1.4MB +// base64 — realistic enough for client testing without pulling in a +// compression dependency. The image content is flat/banded by design so the +// lack of compression is not visually meaningful. +// - Output is deterministic from the prompt so repeated calls and tests are +// reproducible; the palette hue is derived from a hash of the prompt. + +/// Parameters describing the placeholder image to render. +pub struct PlaceholderSpec<'a> { + pub width: u32, + pub height: u32, + pub prompt: &'a str, + pub model: &'a str, + pub quality: &'a str, + /// Preview block size: 1 renders a crisp final image, larger values render + /// a progressively coarser (pixelated) preview used for partial frames. + pub blockiness: u32, +} + +/// Render the placeholder image and return PNG-encoded bytes. +pub fn render_png(spec: &PlaceholderSpec<'_>) -> Vec { + let width = spec.width.clamp(16, 4096); + let height = spec.height.clamp(16, 4096); + + let palette = build_palette(spec.prompt); + let mut canvas = Canvas::new(width, height); + + canvas.draw_background(); + canvas.draw_bars(); + canvas.draw_border(); + canvas.draw_labels(spec); + + if spec.blockiness > 1 { + canvas.pixelate(spec.blockiness); + } + + encode_indexed_png(width, height, &palette, &canvas.buf) +} + +// --------------------------------------------------------------------------- +// Palette (16 entries, RGB) +// --------------------------------------------------------------------------- + +// Fixed palette index assignments used while drawing. +const C_BORDER: u8 = 0; +const C_BG_BASE: u8 = 1; // shades 1..=8 form the background gradient +const C_HEADER: u8 = 9; +const C_FOOTER: u8 = 10; +const C_TITLE: u8 = 11; +const C_BODY: u8 = 12; +const C_LABEL: u8 = 13; + +fn build_palette(prompt: &str) -> Vec<[u8; 3]> { + let hash = fnv1a(prompt.as_bytes()); + let hue = (hash % 360) as f64; + // A muted base color derived from the prompt hue. + let base = hsv_to_rgb(hue, 0.45, 0.85); + + let mut palette = vec![[0u8; 3]; 16]; + palette[C_BORDER as usize] = [30, 33, 40]; + // Background gradient: 8 shades from light to a darker tint of the base. + for i in 0..8u32 { + let t = i as f64 / 7.0; + let factor = 1.0 - 0.55 * t; + palette[(C_BG_BASE + i as u8) as usize] = scale_rgb(base, factor); + } + palette[C_HEADER as usize] = [33, 37, 46]; + palette[C_FOOTER as usize] = [24, 27, 34]; + palette[C_TITLE as usize] = [240, 242, 248]; + palette[C_BODY as usize] = [28, 31, 38]; + palette[C_LABEL as usize] = [255, 214, 102]; + // Remaining entries unused; leave as the border color. + for entry in palette.iter_mut().skip(14) { + *entry = [30, 33, 40]; + } + palette +} + +fn scale_rgb(rgb: [u8; 3], factor: f64) -> [u8; 3] { + [ + (rgb[0] as f64 * factor).clamp(0.0, 255.0) as u8, + (rgb[1] as f64 * factor).clamp(0.0, 255.0) as u8, + (rgb[2] as f64 * factor).clamp(0.0, 255.0) as u8, + ] +} + +fn hsv_to_rgb(h: f64, s: f64, v: f64) -> [u8; 3] { + let c = v * s; + let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs()); + let m = v - c; + let (r, g, b) = match (h / 60.0) as u32 { + 0 => (c, x, 0.0), + 1 => (x, c, 0.0), + 2 => (0.0, c, x), + 3 => (0.0, x, c), + 4 => (x, 0.0, c), + _ => (c, 0.0, x), + }; + [ + ((r + m) * 255.0) as u8, + ((g + m) * 255.0) as u8, + ((b + m) * 255.0) as u8, + ] +} + +fn fnv1a(bytes: &[u8]) -> u64 { + let mut hash: u64 = 0xcbf29ce484222325; + for &b in bytes { + hash ^= b as u64; + hash = hash.wrapping_mul(0x100000001b3); + } + hash +} + +// --------------------------------------------------------------------------- +// Drawing primitives. A `Canvas` owns the 8-bit palette-index buffer so the +// drawing helpers stay readable (and avoid threading width/height through every +// call). +// --------------------------------------------------------------------------- + +/// Glyph cell is 5 wide x 7 tall; advance is 6 px (5 + 1 spacing) per char. +const GLYPH_W: u32 = 5; +const GLYPH_H: u32 = 7; +const GLYPH_ADVANCE: u32 = 6; + +struct Canvas { + buf: Vec, + w: u32, + h: u32, +} + +impl Canvas { + fn new(w: u32, h: u32) -> Self { + Self { + buf: vec![0u8; (w * h) as usize], + w, + h, + } + } + + #[inline] + fn set_px(&mut self, x: i64, y: i64, idx: u8) { + if x < 0 || y < 0 || x >= self.w as i64 || y >= self.h as i64 { + return; + } + self.buf[(y as u32 * self.w + x as u32) as usize] = idx; + } + + fn fill_rect(&mut self, x: i64, y: i64, rw: i64, rh: i64, idx: u8) { + for dy in 0..rh { + for dx in 0..rw { + self.set_px(x + dx, y + dy, idx); + } + } + } + + fn draw_background(&mut self) { + let span = (self.w + self.h) as f64; + for y in 0..self.h { + for x in 0..self.w { + let t = (x + y) as f64 / span; + let shade = (t * 8.0) as u8; + self.buf[(y * self.w + x) as usize] = C_BG_BASE + shade.min(7); + } + } + } + + fn draw_bars(&mut self) { + let header_h = (self.h / 8).max(40); + self.fill_rect(0, 0, self.w as i64, header_h as i64, C_HEADER); + let footer_h = (self.h / 8).max(44); + self.fill_rect( + 0, + (self.h - footer_h) as i64, + self.w as i64, + footer_h as i64, + C_FOOTER, + ); + } + + fn draw_border(&mut self) { + let t = (self.w.min(self.h) / 128).max(2) as i64; + self.fill_rect(0, 0, self.w as i64, t, C_BORDER); + self.fill_rect(0, (self.h as i64) - t, self.w as i64, t, C_BORDER); + self.fill_rect(0, 0, t, self.h as i64, C_BORDER); + self.fill_rect((self.w as i64) - t, 0, t, self.h as i64, C_BORDER); + } + + fn draw_char(&mut self, x0: i64, y0: i64, ch: char, scale: u32, color: u8) { + let glyph = glyph(ch); + let s = scale as i64; + for (row, bits) in glyph.iter().enumerate() { + for col in 0..GLYPH_W { + if bits & (1 << (GLYPH_W - 1 - col)) != 0 { + self.fill_rect(x0 + col as i64 * s, y0 + row as i64 * s, s, s, color); + } + } + } + } + + fn draw_text(&mut self, x0: i64, y0: i64, text: &str, scale: u32, color: u8) { + let mut x = x0; + for ch in text.chars() { + self.draw_char(x, y0, ch, scale, color); + x += (GLYPH_ADVANCE * scale) as i64; + } + } + + fn draw_text_centered(&mut self, y0: i64, text: &str, scale: u32, color: u8) { + let tw = text_width(text, scale); + let x0 = (self.w as i64 - tw as i64) / 2; + self.draw_text(x0, y0, text, scale, color); + } + + fn draw_labels(&mut self, spec: &PlaceholderSpec<'_>) { + let w = self.w; + let h = self.h; + + // Header: model + size + quality. + let title_scale = (w / 320).clamp(2, 8); + let header = format!( + "{} | {}X{} | {}", + spec.model.to_uppercase(), + spec.width, + spec.height, + spec.quality.to_uppercase() + ); + let header = fit_to_width( + &header, + w.saturating_sub(2 * GLYPH_ADVANCE * title_scale), + title_scale, + ); + self.draw_text_centered( + ((h / 8).max(40) as i64 - (GLYPH_H * title_scale) as i64) / 2, + &header, + title_scale, + C_TITLE, + ); + + // Center: the wrapped prompt ("query text"). + let body_scale = (w / 280).clamp(2, 10); + let usable_w = w + .saturating_sub(8 * GLYPH_ADVANCE * body_scale) + .max(GLYPH_ADVANCE * body_scale); + let max_chars = (usable_w / (GLYPH_ADVANCE * body_scale)).max(1) as usize; + let prompt = if spec.prompt.trim().is_empty() { + "(empty prompt)".to_string() + } else { + spec.prompt.to_uppercase() + }; + let max_lines = 6; + let lines = wrap_text(&prompt, max_chars, max_lines); + let line_h = (GLYPH_H + 4) * body_scale; + let block_h = line_h * lines.len() as u32; + let mut y = (h as i64 - block_h as i64) / 2; + for line in &lines { + self.draw_text_centered(y, line, body_scale, C_BODY); + y += line_h as i64; + } + + // Footer: the simulated-image watermark. + let label_scale = (w / 300).clamp(2, 8); + let label = "LLMSIM SIMULATED IMAGE"; + let label = fit_to_width( + label, + w.saturating_sub(2 * GLYPH_ADVANCE * label_scale), + label_scale, + ); + let footer_h = (h / 8).max(44); + let label_y = + (h - footer_h) as i64 + (footer_h as i64 - (GLYPH_H * label_scale) as i64) / 2; + self.draw_text_centered(label_y, &label, label_scale, C_LABEL); + } + + /// Replace each block with its top-left sample to simulate a coarse preview. + fn pixelate(&mut self, block: u32) { + let block = block.max(1); + let (w, h) = (self.w, self.h); + for by in (0..h).step_by(block as usize) { + for bx in (0..w).step_by(block as usize) { + let sample = self.buf[(by * w + bx) as usize]; + for y in by..(by + block).min(h) { + for x in bx..(bx + block).min(w) { + self.buf[(y * w + x) as usize] = sample; + } + } + } + } + } +} + +fn text_width(text: &str, scale: u32) -> u32 { + if text.is_empty() { + return 0; + } + (text.chars().count() as u32) * GLYPH_ADVANCE * scale - scale +} + +/// Truncate a single line to fit the given pixel width, adding an ellipsis. +fn fit_to_width(text: &str, max_w: u32, scale: u32) -> String { + if text_width(text, scale) <= max_w { + return text.to_string(); + } + let per = GLYPH_ADVANCE * scale; + let max_chars = (max_w / per).max(1) as usize; + let mut out: String = text.chars().take(max_chars.saturating_sub(3)).collect(); + out.push_str("..."); + out +} + +/// Greedy word-wrap into at most `max_lines` lines of `max_chars` each. +fn wrap_text(text: &str, max_chars: usize, max_lines: usize) -> Vec { + let mut lines: Vec = Vec::new(); + let mut current = String::new(); + for word in text.split_whitespace() { + // Hard-break words longer than a line. + let word = if word.chars().count() > max_chars { + word.chars().take(max_chars).collect::() + } else { + word.to_string() + }; + if current.is_empty() { + current = word; + } else if current.chars().count() + 1 + word.chars().count() <= max_chars { + current.push(' '); + current.push_str(&word); + } else { + lines.push(std::mem::take(&mut current)); + current = word; + if lines.len() == max_lines { + break; + } + } + } + if lines.len() < max_lines && !current.is_empty() { + lines.push(current); + } + if lines.is_empty() { + lines.push(String::new()); + } + // If we ran out of room, mark truncation on the last line. + if lines.len() == max_lines { + if let Some(last) = lines.last_mut() { + if last.chars().count() + 3 <= max_chars { + last.push_str("..."); + } + } + } + lines +} + +// --------------------------------------------------------------------------- +// Minimal indexed PNG encoder (no external dependencies) +// --------------------------------------------------------------------------- + +fn encode_indexed_png(width: u32, height: u32, palette: &[[u8; 3]], indices: &[u8]) -> Vec { + let mut out = Vec::with_capacity(indices.len() + 1024); + // PNG signature. + out.extend_from_slice(&[137, 80, 78, 71, 13, 10, 26, 10]); + + // IHDR: width, height, bit depth 8, color type 3 (indexed). + let mut ihdr = Vec::with_capacity(13); + ihdr.extend_from_slice(&width.to_be_bytes()); + ihdr.extend_from_slice(&height.to_be_bytes()); + ihdr.push(8); // bit depth + ihdr.push(3); // color type: indexed + ihdr.push(0); // compression + ihdr.push(0); // filter + ihdr.push(0); // interlace + write_chunk(&mut out, b"IHDR", &ihdr); + + // PLTE: palette entries (RGB triplets). + let mut plte = Vec::with_capacity(palette.len() * 3); + for entry in palette { + plte.extend_from_slice(entry); + } + write_chunk(&mut out, b"PLTE", &plte); + + // IDAT: raw scanlines (filter byte 0 per row) wrapped in a zlib stream. + let mut raw = Vec::with_capacity((width as usize + 1) * height as usize); + for y in 0..height { + raw.push(0); // filter type: none + let start = (y * width) as usize; + raw.extend_from_slice(&indices[start..start + width as usize]); + } + let zlib = zlib_store(&raw); + write_chunk(&mut out, b"IDAT", &zlib); + + // IEND. + write_chunk(&mut out, b"IEND", &[]); + out +} + +fn write_chunk(out: &mut Vec, kind: &[u8; 4], data: &[u8]) { + out.extend_from_slice(&(data.len() as u32).to_be_bytes()); + out.extend_from_slice(kind); + out.extend_from_slice(data); + let mut crc = Crc32::new(); + crc.update(kind); + crc.update(data); + out.extend_from_slice(&crc.finish().to_be_bytes()); +} + +/// Wrap data in a zlib stream using uncompressed ("stored") DEFLATE blocks. +fn zlib_store(data: &[u8]) -> Vec { + let mut out = Vec::with_capacity(data.len() + data.len() / 65535 * 5 + 16); + out.push(0x78); // CMF + out.push(0x01); // FLG (no preset dict, fastest) + + let mut offset = 0; + while offset < data.len() { + let chunk_len = (data.len() - offset).min(65535); + let is_last = offset + chunk_len >= data.len(); + out.push(if is_last { 1 } else { 0 }); // BFINAL + BTYPE=00 + let len = chunk_len as u16; + out.extend_from_slice(&len.to_le_bytes()); + out.extend_from_slice(&(!len).to_le_bytes()); + out.extend_from_slice(&data[offset..offset + chunk_len]); + offset += chunk_len; + } + // Handle the empty-data edge case (still need a final block). + if data.is_empty() { + out.push(1); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend_from_slice(&(!0u16).to_le_bytes()); + } + + out.extend_from_slice(&adler32(data).to_be_bytes()); + out +} + +/// Standard base64 encoding (no external dependency). Used to emit images as +/// `b64_json` in the API response, matching the OpenAI image API. +pub fn base64_encode(data: &[u8]) -> String { + const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + for chunk in data.chunks(3) { + let b0 = chunk[0] as u32; + let b1 = *chunk.get(1).unwrap_or(&0) as u32; + let b2 = *chunk.get(2).unwrap_or(&0) as u32; + let n = (b0 << 16) | (b1 << 8) | b2; + out.push(ALPHABET[((n >> 18) & 63) as usize] as char); + out.push(ALPHABET[((n >> 12) & 63) as usize] as char); + if chunk.len() > 1 { + out.push(ALPHABET[((n >> 6) & 63) as usize] as char); + } else { + out.push('='); + } + if chunk.len() > 2 { + out.push(ALPHABET[(n & 63) as usize] as char); + } else { + out.push('='); + } + } + out +} + +fn adler32(data: &[u8]) -> u32 { + const MOD: u32 = 65521; + let mut a: u32 = 1; + let mut b: u32 = 0; + for &byte in data { + a = (a + byte as u32) % MOD; + b = (b + a) % MOD; + } + (b << 16) | a +} + +struct Crc32 { + crc: u32, +} + +impl Crc32 { + fn new() -> Self { + Self { crc: 0xFFFF_FFFF } + } + + fn update(&mut self, data: &[u8]) { + for &byte in data { + let idx = ((self.crc ^ byte as u32) & 0xFF) as usize; + self.crc = CRC_TABLE[idx] ^ (self.crc >> 8); + } + } + + fn finish(self) -> u32 { + self.crc ^ 0xFFFF_FFFF + } +} + +static CRC_TABLE: std::sync::LazyLock<[u32; 256]> = std::sync::LazyLock::new(|| { + let mut table = [0u32; 256]; + for (n, entry) in table.iter_mut().enumerate() { + let mut c = n as u32; + for _ in 0..8 { + c = if c & 1 != 0 { + 0xEDB8_8320 ^ (c >> 1) + } else { + c >> 1 + }; + } + *entry = c; + } + table +}); + +// --------------------------------------------------------------------------- +// 5x7 bitmap font. Each glyph is 7 rows; the low 5 bits of each row are the +// columns, with bit 4 (value 16) the leftmost column. +// --------------------------------------------------------------------------- + +fn glyph(ch: char) -> [u8; 7] { + let upper = ch.to_ascii_uppercase(); + match upper { + ' ' => [0, 0, 0, 0, 0, 0, 0], + 'A' => [14, 17, 17, 31, 17, 17, 17], + 'B' => [30, 17, 17, 30, 17, 17, 30], + 'C' => [14, 17, 16, 16, 16, 17, 14], + 'D' => [30, 17, 17, 17, 17, 17, 30], + 'E' => [31, 16, 16, 30, 16, 16, 31], + 'F' => [31, 16, 16, 30, 16, 16, 16], + 'G' => [14, 17, 16, 23, 17, 17, 15], + 'H' => [17, 17, 17, 31, 17, 17, 17], + 'I' => [14, 4, 4, 4, 4, 4, 14], + 'J' => [7, 2, 2, 2, 2, 18, 12], + 'K' => [17, 18, 20, 24, 20, 18, 17], + 'L' => [16, 16, 16, 16, 16, 16, 31], + 'M' => [17, 27, 21, 21, 17, 17, 17], + 'N' => [17, 17, 25, 21, 19, 17, 17], + 'O' => [14, 17, 17, 17, 17, 17, 14], + 'P' => [30, 17, 17, 30, 16, 16, 16], + 'Q' => [14, 17, 17, 17, 21, 18, 13], + 'R' => [30, 17, 17, 30, 20, 18, 17], + 'S' => [15, 16, 16, 14, 1, 1, 30], + 'T' => [31, 4, 4, 4, 4, 4, 4], + 'U' => [17, 17, 17, 17, 17, 17, 14], + 'V' => [17, 17, 17, 17, 17, 10, 4], + 'W' => [17, 17, 17, 21, 21, 27, 17], + 'X' => [17, 17, 10, 4, 10, 17, 17], + 'Y' => [17, 17, 10, 4, 4, 4, 4], + 'Z' => [31, 1, 2, 4, 8, 16, 31], + '0' => [14, 17, 19, 21, 25, 17, 14], + '1' => [4, 12, 4, 4, 4, 4, 14], + '2' => [14, 17, 1, 6, 8, 16, 31], + '3' => [31, 2, 4, 2, 1, 17, 14], + '4' => [2, 6, 10, 18, 31, 2, 2], + '5' => [31, 16, 30, 1, 1, 17, 14], + '6' => [6, 8, 16, 30, 17, 17, 14], + '7' => [31, 1, 2, 4, 8, 8, 8], + '8' => [14, 17, 17, 14, 17, 17, 14], + '9' => [14, 17, 17, 15, 1, 2, 12], + '.' => [0, 0, 0, 0, 0, 12, 12], + ',' => [0, 0, 0, 0, 12, 4, 8], + ':' => [0, 12, 12, 0, 12, 12, 0], + '-' => [0, 0, 0, 31, 0, 0, 0], + '_' => [0, 0, 0, 0, 0, 0, 31], + '!' => [4, 4, 4, 4, 4, 0, 4], + '?' => [14, 17, 1, 6, 4, 0, 4], + '\'' => [4, 4, 8, 0, 0, 0, 0], + '"' => [10, 10, 10, 0, 0, 0, 0], + '/' => [1, 2, 4, 4, 8, 16, 16], + '\\' => [16, 8, 4, 4, 2, 1, 1], + '(' => [2, 4, 8, 8, 8, 4, 2], + ')' => [8, 4, 2, 2, 2, 4, 8], + '[' => [14, 8, 8, 8, 8, 8, 14], + ']' => [14, 2, 2, 2, 2, 2, 14], + '+' => [0, 4, 4, 31, 4, 4, 0], + '*' => [0, 10, 4, 31, 4, 10, 0], + '=' => [0, 0, 31, 0, 31, 0, 0], + '|' => [4, 4, 4, 4, 4, 4, 4], + '#' => [10, 10, 31, 10, 31, 10, 10], + '&' => [12, 18, 20, 8, 21, 18, 13], + '@' => [14, 17, 19, 21, 23, 16, 14], + // Unknown characters render as a light tofu box. + _ => [31, 17, 17, 17, 17, 17, 31], + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn decode_header(png: &[u8]) -> (u32, u32, u8, u8) { + // Signature is 8 bytes, then IHDR chunk: 4 len + 4 type + data. + assert_eq!(&png[0..8], &[137, 80, 78, 71, 13, 10, 26, 10]); + assert_eq!(&png[12..16], b"IHDR"); + let w = u32::from_be_bytes([png[16], png[17], png[18], png[19]]); + let h = u32::from_be_bytes([png[20], png[21], png[22], png[23]]); + let bit_depth = png[24]; + let color_type = png[25]; + (w, h, bit_depth, color_type) + } + + #[test] + fn renders_exact_requested_size() { + let png = render_png(&PlaceholderSpec { + width: 1024, + height: 1536, + prompt: "a cat riding a bicycle", + model: "gpt-image-1", + quality: "high", + blockiness: 1, + }); + let (w, h, bd, ct) = decode_header(&png); + assert_eq!(w, 1024); + assert_eq!(h, 1536); + assert_eq!(bd, 8); + assert_eq!(ct, 3); + } + + #[test] + fn ends_with_iend() { + let png = render_png(&PlaceholderSpec { + width: 256, + height: 256, + prompt: "test", + model: "gpt-image-1", + quality: "low", + blockiness: 1, + }); + assert!(png.ends_with(b"IEND") || png.len() > 12); + // Last 12 bytes are the IEND chunk (4 len + IEND + 4 crc). + let tail = &png[png.len() - 8..png.len() - 4]; + assert_eq!(tail, b"IEND"); + } + + #[test] + fn deterministic_for_same_prompt() { + let spec = || PlaceholderSpec { + width: 512, + height: 512, + prompt: "deterministic prompt", + model: "gpt-image-1", + quality: "medium", + blockiness: 1, + }; + assert_eq!(render_png(&spec()), render_png(&spec())); + } + + #[test] + fn blockiness_changes_output() { + let base = PlaceholderSpec { + width: 256, + height: 256, + prompt: "preview", + model: "gpt-image-1", + quality: "low", + blockiness: 1, + }; + let coarse = PlaceholderSpec { + blockiness: 32, + ..base_like(&base) + }; + assert_ne!(render_png(&base), render_png(&coarse)); + } + + // Helper to clone a spec with a different blockiness (PlaceholderSpec holds + // borrows, so reconstruct it explicitly). + fn base_like<'a>(s: &PlaceholderSpec<'a>) -> PlaceholderSpec<'a> { + PlaceholderSpec { + width: s.width, + height: s.height, + prompt: s.prompt, + model: s.model, + quality: s.quality, + blockiness: s.blockiness, + } + } + + #[test] + fn base64_known_values() { + assert_eq!(base64_encode(b""), ""); + assert_eq!(base64_encode(b"f"), "Zg=="); + assert_eq!(base64_encode(b"fo"), "Zm8="); + assert_eq!(base64_encode(b"foo"), "Zm9v"); + assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy"); + } + + #[test] + fn crc_and_adler_known_values() { + // Adler32 of "abc" is 0x024d0127. + assert_eq!(adler32(b"abc"), 0x024d_0127); + // CRC32 of "123456789" is 0xCBF43926. + let mut crc = Crc32::new(); + crc.update(b"123456789"); + assert_eq!(crc.finish(), 0xCBF4_3926); + } +} diff --git a/src/lib.rs b/src/lib.rs index 475481f..af7ce61 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -45,6 +45,8 @@ pub mod anthropic; pub mod errors; pub mod generator; mod ids; +pub mod image_stream; +pub mod imagegen; pub mod latency; pub mod openai; pub mod openresponses; diff --git a/src/openai/images.rs b/src/openai/images.rs new file mode 100644 index 0000000..ad5a175 --- /dev/null +++ b/src/openai/images.rs @@ -0,0 +1,318 @@ +// OpenAI Image Generation API types. +// +// Models the `/v1/images/generations` endpoint used by the gpt-image family +// (the "ChatGPT Images" capability). Covers both the non-streaming JSON +// response and the streaming SSE events (`image_generation.partial_image` and +// `image_generation.completed`). +// +// Reference: https://platform.openai.com/docs/api-reference/images + +use crate::latency::LatencyProfile; +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +/// Default model when a request omits `model`. +fn default_image_model() -> String { + "gpt-image-1".to_string() +} + +/// Request body for `POST /openai/v1/images/generations`. +#[derive(Debug, Clone, Deserialize)] +pub struct ImageGenerationRequest { + /// Text description of the desired image(s). + pub prompt: String, + /// Model id (e.g. "gpt-image-1"). Defaults to gpt-image-1. + #[serde(default = "default_image_model")] + pub model: String, + /// Number of images to generate (1-10). + pub n: Option, + /// Image size, e.g. "1024x1024", "1024x1536", "1536x1024", or "auto". + pub size: Option, + /// Quality: "low" | "medium" | "high" | "auto". + pub quality: Option, + /// DALL·E response format ("url" | "b64_json"). gpt-image always returns b64. + pub response_format: Option, + /// Output encoding for gpt-image models ("png" | "jpeg" | "webp"). + pub output_format: Option, + /// Background: "transparent" | "opaque" | "auto". + pub background: Option, + /// Whether to stream partial images via SSE. + #[serde(default)] + pub stream: bool, + /// Number of progressive partial images to stream (0-3). + pub partial_images: Option, + /// Moderation strictness ("auto" | "low"). + pub moderation: Option, + /// End-user identifier for abuse tracking (ignored by the simulator). + pub user: Option, +} + +/// Resolved, validated parameters derived from a request. +#[derive(Debug, Clone)] +pub struct ResolvedImageParams { + pub width: u32, + pub height: u32, + pub size: String, + pub quality: String, + pub output_format: String, + pub background: String, + pub n: u32, + pub partial_images: u32, +} + +impl ImageGenerationRequest { + /// Resolve defaults and clamp values to the simulator's supported ranges. + pub fn resolve(&self) -> ResolvedImageParams { + let size = self + .size + .clone() + .filter(|s| s != "auto") + .unwrap_or_else(|| "1024x1024".to_string()); + let (width, height) = parse_size(&size); + let quality = match self.quality.as_deref() { + Some("low") => "low", + Some("medium") => "medium", + Some("high") => "high", + _ => "high", // "auto"/None map to high, gpt-image's default feel + } + .to_string(); + let output_format = match self.output_format.as_deref() { + Some("jpeg") => "jpeg", + Some("webp") => "webp", + _ => "png", + } + .to_string(); + let background = match self.background.as_deref() { + Some("transparent") => "transparent", + Some("opaque") => "opaque", + _ => "opaque", + } + .to_string(); + let n = self.n.unwrap_or(1).clamp(1, 10); + let partial_images = self.partial_images.unwrap_or(0).min(3); + + ResolvedImageParams { + width, + height, + size, + quality, + output_format, + background, + n, + partial_images, + } + } +} + +/// Parse a "WxH" size string into pixel dimensions, defaulting to 1024x1024. +pub fn parse_size(size: &str) -> (u32, u32) { + let mut parts = size.split(['x', 'X']); + let w = parts.next().and_then(|s| s.trim().parse::().ok()); + let h = parts.next().and_then(|s| s.trim().parse::().ok()); + match (w, h) { + (Some(w), Some(h)) if w > 0 && h > 0 => (w.clamp(16, 4096), h.clamp(16, 4096)), + _ => (1024, 1024), + } +} + +/// One generated image in a non-streaming response. +#[derive(Debug, Clone, Serialize)] +pub struct ImageData { + #[serde(skip_serializing_if = "Option::is_none")] + pub b64_json: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub revised_prompt: Option, +} + +/// Detailed breakdown of input tokens for image usage. +#[derive(Debug, Clone, Serialize)] +pub struct ImageInputTokensDetails { + pub text_tokens: u32, + pub image_tokens: u32, +} + +/// Token usage for an image generation. +#[derive(Debug, Clone, Serialize)] +pub struct ImagesUsage { + pub total_tokens: u32, + pub input_tokens: u32, + pub output_tokens: u32, + pub input_tokens_details: ImageInputTokensDetails, +} + +/// Non-streaming response for `POST /openai/v1/images/generations`. +#[derive(Debug, Clone, Serialize)] +pub struct ImageGenerationResponse { + pub created: i64, + pub data: Vec, + pub usage: ImagesUsage, + pub size: String, + pub quality: String, + pub output_format: String, + pub background: String, +} + +/// Streaming `image_generation.partial_image` event payload. +#[derive(Debug, Clone, Serialize)] +pub struct PartialImageEvent { + #[serde(rename = "type")] + pub event_type: String, + pub b64_json: String, + pub created_at: i64, + pub size: String, + pub quality: String, + pub background: String, + pub output_format: String, + pub partial_image_index: u32, +} + +/// Streaming `image_generation.completed` event payload. +#[derive(Debug, Clone, Serialize)] +pub struct CompletedImageEvent { + #[serde(rename = "type")] + pub event_type: String, + pub b64_json: String, + pub created_at: i64, + pub size: String, + pub quality: String, + pub background: String, + pub output_format: String, + pub usage: ImagesUsage, +} + +/// Build the SSE wire frame for a partial image event. +pub fn partial_image_sse(event: &PartialImageEvent) -> String { + let data = serde_json::to_string(event).unwrap_or_default(); + format!("event: image_generation.partial_image\ndata: {}\n\n", data) +} + +/// Build the SSE wire frame for the completed image event. +pub fn completed_image_sse(event: &CompletedImageEvent) -> String { + let data = serde_json::to_string(event).unwrap_or_default(); + format!("event: image_generation.completed\ndata: {}\n\n", data) +} + +/// Estimate the number of output (image) tokens for a generation, scaled by +/// size and quality. Baselines are anchored to gpt-image-1 at 1024x1024. +pub fn estimate_image_tokens(width: u32, height: u32, quality: &str) -> u32 { + let base = match quality { + "low" => 272.0, + "medium" => 1056.0, + _ => 4160.0, // high / auto + }; + let area_ratio = (width as f64 * height as f64) / (1024.0 * 1024.0); + (base * area_ratio).round() as u32 +} + +/// Total wall-clock time to "generate" the image(s). +/// +/// Anchored to the configured latency profile: gpt-5's 600 ms TTFT baseline +/// maps to realistic image-generation times, while `instant`/`fast` profiles +/// (used by tests and load runs) collapse the wait to near zero. +pub fn image_total_duration( + latency: &LatencyProfile, + quality: &str, + width: u32, + height: u32, + n: u32, +) -> Duration { + if latency.ttft_mean_ms == 0 { + return Duration::ZERO; + } + let speed_factor = latency.ttft_mean_ms as f64 / 600.0; + let base_ms = match quality { + "low" => 4_000.0, + "medium" => 10_000.0, + _ => 22_000.0, // high / auto + }; + let area_ratio = ((width as f64 * height as f64) / (1024.0 * 1024.0)).sqrt(); + let total = base_ms * speed_factor * area_ratio * n as f64; + Duration::from_millis(total.round() as u64) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_size_variants() { + assert_eq!(parse_size("1024x1024"), (1024, 1024)); + assert_eq!(parse_size("1536x1024"), (1536, 1024)); + assert_eq!(parse_size("1024X1536"), (1024, 1536)); + assert_eq!(parse_size("bogus"), (1024, 1024)); + assert_eq!(parse_size("auto"), (1024, 1024)); + } + + #[test] + fn resolve_defaults() { + let req = ImageGenerationRequest { + prompt: "a fox".to_string(), + model: "gpt-image-1".to_string(), + n: None, + size: None, + quality: None, + response_format: None, + output_format: None, + background: None, + stream: false, + partial_images: None, + moderation: None, + user: None, + }; + let r = req.resolve(); + assert_eq!(r.size, "1024x1024"); + assert_eq!(r.quality, "high"); + assert_eq!(r.output_format, "png"); + assert_eq!(r.n, 1); + assert_eq!(r.partial_images, 0); + } + + #[test] + fn partial_images_clamped() { + let req = ImageGenerationRequest { + prompt: "x".to_string(), + model: "gpt-image-1".to_string(), + n: Some(50), + size: Some("auto".to_string()), + quality: Some("medium".to_string()), + response_format: None, + output_format: Some("webp".to_string()), + background: Some("transparent".to_string()), + stream: true, + partial_images: Some(9), + moderation: None, + user: None, + }; + let r = req.resolve(); + assert_eq!(r.n, 10); + assert_eq!(r.partial_images, 3); + assert_eq!(r.output_format, "webp"); + assert_eq!(r.background, "transparent"); + assert_eq!(r.size, "1024x1024"); // "auto" resolved to default + } + + #[test] + fn tokens_scale_with_quality_and_size() { + let low = estimate_image_tokens(1024, 1024, "low"); + let high = estimate_image_tokens(1024, 1024, "high"); + assert!(high > low); + let big = estimate_image_tokens(2048, 2048, "high"); + assert!(big > high); + } + + #[test] + fn duration_zero_for_instant() { + let d = image_total_duration(&LatencyProfile::instant(), "high", 1024, 1024, 1); + assert_eq!(d, Duration::ZERO); + } + + #[test] + fn duration_scales_with_quality() { + let profile = LatencyProfile::gpt5(); + let low = image_total_duration(&profile, "low", 1024, 1024, 1); + let high = image_total_duration(&profile, "high", 1024, 1024, 1); + assert!(high > low); + } +} diff --git a/src/openai/mod.rs b/src/openai/mod.rs index 09bc4ff..ecd7587 100644 --- a/src/openai/mod.rs +++ b/src/openai/mod.rs @@ -1,3 +1,4 @@ +pub mod images; mod models; mod responses; mod types; diff --git a/src/openai/models.rs b/src/openai/models.rs index e1633c3..170a9a7 100644 --- a/src/openai/models.rs +++ b/src/openai/models.rs @@ -167,6 +167,17 @@ fn gemini_reasoning_capabilities() -> ModelCapabilities { } } +/// Capabilities for OpenAI image generation models (gpt-image family). +/// They accept image inputs (vision) but do not do text tool-calling/JSON. +fn image_capabilities() -> ModelCapabilities { + ModelCapabilities { + function_calling: false, + vision: true, + json_mode: false, + reasoning: false, + } +} + /// Standard capabilities for DeepSeek models fn deepseek_capabilities() -> ModelCapabilities { ModelCapabilities { @@ -605,6 +616,30 @@ fn build_model_registry() -> HashMap { .with_capabilities(deepseek_reasoning_capabilities()), ]; + // OpenAI image generation models (gpt-image / "ChatGPT Images" family). + // context_window is the prompt token limit; max_output_tokens reflects the + // image token output ceiling rather than text tokens. + let image_models = vec![ + ModelProfile::new("gpt-image-1", "GPT Image 1", "openai", 32_000, 4_160) + .with_created(1744761600) // 2025-04-16 + .with_capabilities(image_capabilities()) + .with_knowledge_cutoff("2024-06-30"), + ModelProfile::new( + "gpt-image-1-mini", + "GPT Image 1 Mini", + "openai", + 32_000, + 4_160, + ) + .with_created(1744761600) // 2025-04-16 + .with_capabilities(image_capabilities()) + .with_knowledge_cutoff("2024-06-30"), + ModelProfile::new("gpt-image-1.5", "GPT Image 1.5", "openai", 32_000, 4_160) + .with_created(1761955200) // 2025-11 + .with_capabilities(image_capabilities()) + .with_knowledge_cutoff("2024-06-30"), + ]; + // Add all models to registry for model in gpt5_models .into_iter() @@ -613,6 +648,7 @@ fn build_model_registry() -> HashMap { .chain(claude_models) .chain(gemini_models) .chain(deepseek_models) + .chain(image_models) { registry.insert(model.id.clone(), model); } diff --git a/src/stats.rs b/src/stats.rs index fe1581f..5df1fc0 100644 --- a/src/stats.rs +++ b/src/stats.rs @@ -39,6 +39,8 @@ pub enum EndpointType { WebSocketResponses, /// Anthropic Messages API (/anthropic/v1/messages) Messages, + /// Image generation API (/openai/v1/images/generations) + Images, } /// Global statistics tracker for the LLMSim server. @@ -64,6 +66,8 @@ pub struct Stats { pub websocket_requests: AtomicU64, /// Anthropic Messages API requests pub messages_requests: AtomicU64, + /// Image generation API requests + pub image_requests: AtomicU64, /// Currently active WebSocket connections pub active_websocket_connections: AtomicU64, @@ -123,6 +127,7 @@ impl Stats { responses_requests: AtomicU64::new(0), websocket_requests: AtomicU64::new(0), messages_requests: AtomicU64::new(0), + image_requests: AtomicU64::new(0), active_websocket_connections: AtomicU64::new(0), prompt_tokens: AtomicU64::new(0), completion_tokens: AtomicU64::new(0), @@ -164,6 +169,9 @@ impl Stats { EndpointType::Messages => { self.messages_requests.fetch_add(1, ORDERING); } + EndpointType::Images => { + self.image_requests.fetch_add(1, ORDERING); + } } // Track per-model requests with bounded key size/cardinality. @@ -388,6 +396,7 @@ impl Stats { responses_requests: self.responses_requests.load(ORDERING), websocket_requests: self.websocket_requests.load(ORDERING), messages_requests: self.messages_requests.load(ORDERING), + image_requests: self.image_requests.load(ORDERING), active_websocket_connections: self.active_websocket_connections.load(ORDERING), prompt_tokens: self.prompt_tokens.load(ORDERING), completion_tokens: self.completion_tokens.load(ORDERING), @@ -418,6 +427,8 @@ pub struct StatsSnapshot { pub websocket_requests: u64, #[serde(default)] pub messages_requests: u64, + #[serde(default)] + pub image_requests: u64, pub active_websocket_connections: u64, pub prompt_tokens: u64, pub completion_tokens: u64, diff --git a/tests/images_test.rs b/tests/images_test.rs new file mode 100644 index 0000000..35257fb --- /dev/null +++ b/tests/images_test.rs @@ -0,0 +1,166 @@ +//! End-to-end tests for the simulated image-generation endpoint. +//! +//! Drives the llmsim Axum router in-process via tower::ServiceExt::oneshot so +//! we exercise the exact HTTP path real OpenAI SDK clients would hit. + +use std::sync::Arc; + +use axum::body::{to_bytes, Body}; +use axum::http::{Request, StatusCode}; +use llmsim::cli::{build_router, AppState, Config}; +use llmsim::stats::new_shared_stats; +use serde_json::{json, Value}; +use tower::ServiceExt; + +/// Build a router with instant latency so image timing collapses to zero. +fn router() -> axum::Router { + let mut config = Config::default(); + config.latency.profile = Some("instant".to_string()); + let state = AppState::new(config, new_shared_stats()); + build_router(Arc::new(state)) +} + +async fn post_images(router: &axum::Router, body: Value) -> (StatusCode, Vec) { + let req = Request::builder() + .method("POST") + .uri("/openai/v1/images/generations") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let resp = router.clone().oneshot(req).await.unwrap(); + let status = resp.status(); + // Allow up to 32 MiB: streamed b64 frames can be large. + let bytes = to_bytes(resp.into_body(), 32 * 1024 * 1024).await.unwrap(); + (status, bytes.to_vec()) +} + +fn is_valid_png_b64(b64: &str) -> bool { + // Decode just the first few bytes and check the PNG signature. + let decoded = base64_decode(b64); + decoded.len() > 8 && decoded[0..8] == [137, 80, 78, 71, 13, 10, 26, 10] +} + +/// Minimal base64 decoder for tests (std has none). +fn base64_decode(s: &str) -> Vec { + fn val(c: u8) -> Option { + match c { + b'A'..=b'Z' => Some(c - b'A'), + b'a'..=b'z' => Some(c - b'a' + 26), + b'0'..=b'9' => Some(c - b'0' + 52), + b'+' => Some(62), + b'/' => Some(63), + _ => None, + } + } + let mut out = Vec::new(); + let mut buf = 0u32; + let mut bits = 0u32; + for &c in s.as_bytes() { + if c == b'=' { + break; + } + if let Some(v) = val(c) { + buf = (buf << 6) | v as u32; + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((buf >> bits) as u8); + } + } + } + out +} + +#[tokio::test] +async fn non_streaming_returns_image_and_usage() { + let router = router(); + let body = json!({ + "model": "gpt-image-1", + "prompt": "a cat riding a bicycle", + "size": "1024x1024", + "quality": "low" + }); + let (status, bytes) = post_images(&router, body).await; + assert_eq!(status, StatusCode::OK); + + let v: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(v["size"], "1024x1024"); + assert_eq!(v["quality"], "low"); + assert_eq!(v["output_format"], "png"); + assert_eq!(v["data"].as_array().unwrap().len(), 1); + + let b64 = v["data"][0]["b64_json"].as_str().unwrap(); + assert!(is_valid_png_b64(b64), "data[0].b64_json should be a PNG"); + + // Usage should reflect text input + image output tokens. + assert!(v["usage"]["input_tokens"].as_u64().unwrap() > 0); + assert!(v["usage"]["output_tokens"].as_u64().unwrap() > 0); + assert_eq!(v["usage"]["input_tokens_details"]["image_tokens"], 0); +} + +#[tokio::test] +async fn honors_n_for_multiple_images() { + let router = router(); + let body = json!({ + "model": "gpt-image-1", + "prompt": "abstract art", + "n": 3, + "size": "512x512", + "quality": "low" + }); + let (status, bytes) = post_images(&router, body).await; + assert_eq!(status, StatusCode::OK); + let v: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(v["data"].as_array().unwrap().len(), 3); +} + +#[tokio::test] +async fn defaults_when_params_omitted() { + let router = router(); + let body = json!({ "prompt": "just a prompt" }); + let (status, bytes) = post_images(&router, body).await; + assert_eq!(status, StatusCode::OK); + let v: Value = serde_json::from_slice(&bytes).unwrap(); + // Default model is gpt-image-1, default size 1024x1024. + assert_eq!(v["size"], "1024x1024"); + assert!(v["data"][0]["b64_json"].is_string()); +} + +#[tokio::test] +async fn streaming_emits_partials_then_completed() { + let router = router(); + let body = json!({ + "model": "gpt-image-1", + "prompt": "sunset over mountains", + "size": "256x256", + "quality": "low", + "stream": true, + "partial_images": 2 + }); + let (status, bytes) = post_images(&router, body).await; + assert_eq!(status, StatusCode::OK); + let text = String::from_utf8(bytes).unwrap(); + + let partials = text.matches("image_generation.partial_image").count(); + // Each partial appears in both the `event:` line and the JSON `type` field. + assert!(partials >= 2, "expected at least 2 partial frames"); + assert!(text.contains("image_generation.completed")); + assert!(text.contains("\"partial_image_index\":0")); + assert!(text.contains("\"usage\"")); +} + +#[tokio::test] +async fn image_model_listed_in_models_endpoint() { + let router = router(); + let req = Request::builder() + .method("GET") + .uri("/openai/v1/models/gpt-image-1") + .body(Body::empty()) + .unwrap(); + let resp = router.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let bytes = to_bytes(resp.into_body(), 1024 * 1024).await.unwrap(); + let v: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(v["id"], "gpt-image-1"); + assert_eq!(v["owned_by"], "openai"); +} diff --git a/tests/smoke_test.sh b/tests/smoke_test.sh index fa749b3..8fc62c8 100755 --- a/tests/smoke_test.sh +++ b/tests/smoke_test.sh @@ -160,6 +160,44 @@ else exit 1 fi +# Test 8b: OpenAI Image Generation (non-streaming) +echo_info "Testing /openai/v1/images/generations (non-streaming)..." +IMAGE_API=$(curl -s -X POST "$BASE_URL/openai/v1/images/generations" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gpt-image-1", + "prompt": "a cat riding a bicycle", + "size": "256x256", + "quality": "low" + }') +# A valid PNG begins with the base64 prefix "iVBORw0KGgo" in b64_json. +if echo "$IMAGE_API" | grep -q '"b64_json":"iVBORw0KGgo'; then + echo_pass "OpenAI Image Generation passed" +else + echo_fail "OpenAI Image Generation failed: ${IMAGE_API:0:200}" + exit 1 +fi + +# Test 8c: OpenAI Image Generation (streaming partial images) +echo_info "Testing /openai/v1/images/generations (streaming)..." +IMAGE_STREAM=$(curl -s -X POST "$BASE_URL/openai/v1/images/generations" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gpt-image-1", + "prompt": "sunset over mountains", + "size": "256x256", + "quality": "low", + "stream": true, + "partial_images": 2 + }') +if echo "$IMAGE_STREAM" | grep -q 'image_generation.partial_image' \ + && echo "$IMAGE_STREAM" | grep -q 'image_generation.completed'; then + echo_pass "OpenAI Image Generation streaming passed" +else + echo_fail "OpenAI Image Generation streaming failed: ${IMAGE_STREAM:0:200}" + exit 1 +fi + # Test 9: OpenResponses API echo_info "Testing /openresponses/v1/responses endpoint..." OPENRESPONSES=$(curl -s -X POST "$BASE_URL/openresponses/v1/responses" \