diff --git a/README.md b/README.md index 6edde01..f518fb8 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,15 @@ llm-connect spend week --json llm-connect balance # current default provider llm-connect balance --provider openrouter # one-shot backend select llm-connect balance --json + +# Square text-to-image. The model and the quote are required when a cap is set. +# Receipt JSON on stdout; image bytes in --output. Credentials stay in the env. +llm-connect image render \ + --prompt "precise technical illustration of a gear, no text" \ + --model "" \ + --output visuals/gear.jpg \ + --max-cost-usd 0.15 \ + --quote-usd 0.08 ``` Usage events append to a JSONL ledger (default diff --git a/SCOPE.md b/SCOPE.md index a3b18d3..e4ffa3c 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -31,6 +31,9 @@ re-export shim pointing here. - Embedding adapters including `EmbeddingAdapter`, `OpenAICompatibleEmbeddingAdapter`, `EmbeddingCache`, and `create_embedding_adapter`. +- Square text-to-image rendering (`llm_connect.images`) with an OpenRouter + client, a per-call and batch spend gate, and a provenance receipt. + See `contracts/functional/images.md`. - TOML-based configuration resolution via `toml_config.py` and `config.py`. - Shared HTTP utilities, token estimation, similarity helpers, and the `LLMError` exception hierarchy. diff --git a/contracts/functional/images.md b/contracts/functional/images.md new file mode 100644 index 0000000..5737de5 --- /dev/null +++ b/contracts/functional/images.md @@ -0,0 +1,77 @@ +# Text-to-image rendering + +`llm_connect.images` renders one square image from a prompt. It does not +choose a model or a budget. The caller passes both. Credentials stay in the +provider client and never appear on a receipt. + +## Request + +- Prompt: non-empty, at most 16 000 characters. +- Size: `WIDTHxHEIGHT`, square, edge from 1024 through 4096. Default + `1024x1024`. +- Format: `png` or `jpeg`. A file suffix `.png`, `.jpg`, or `.jpeg` must + match the format. +- An existing output file is left untouched unless `force` is set. + +## Receipt + +Each successful render returns image bytes and a receipt: + +- `provider`, `model` +- `prompt_digest` — SHA-256 of the UTF-8 prompt. The prompt text is not stored. +- `cost_usd`, `cost_eur`, `cost_source` (`provider`, `quote`, or `unpriced`) +- `width`, `height`, `media_type`, `output_format`, `byte_length` +- `output_path` when a file was written +- `within_cap` — false when the provider cost exceeded a cap that was checked + before the call. The file is still written, because the provider already + charged it, and the batch stops. + +## Spend gate + +`ImageSpendPolicy` takes `max_cost_usd`, `max_batch_cost_usd`, and +`quote_usd`. A cap requires a quote. The quote is reserved before the +provider call. The provider-reported `usage.cost` replaces it on the receipt +when present. A failed HTTP call releases the reservation. Parallel `/images` +requests share one gate for the life of the server process. + +## Provider + +OpenRouter only, via `POST {api_base}/images` (default +`https://openrouter.ai/api/v1/images`). The request sends `n=1`, +`aspect_ratio=1:1`, an explicit pixel `size`, and `output_format`. The key +comes from the explicit argument, `OPENROUTER_API_KEY`, or +`apikey-openrouter.txt`. Only `b64_json` is accepted. A URL in the response +is not fetched. + +There is no default image model. + +## CLI + +```bash +llm-connect image render \ + --prompt "..." \ + --model "" \ + --output visuals/name.jpg \ + --max-cost-usd 0.15 \ + --max-batch-cost-usd 15 \ + --quote-usd 0.08 + +llm-connect image render --batch renders.jsonl --model "" \ + --max-cost-usd 0.15 --max-batch-cost-usd 15 --quote-usd 0.08 +``` + +A batch file is JSONL. Each object has `prompt` and `output_path`. The +receipt JSON goes to stdout. Image bytes stay in the output file. Exit `2` +means a cap stopped the batch after any already-paid files were written. + +## HTTP + +`POST /images` exists when the server is started with `--image-model`, +`--image-max-cost-usd`, and `--image-quote-usd`. Without those flags the +path is not found. The body is `prompt`, optional `size`, and optional +`output_format`. The response is `receipt` plus `image_base64`. Caps are the +server's, not the caller's. `/execute` is unchanged. + +Usage rows are appended only when `LLM_CONNECT_USAGE_LEDGER` is set (server) +or unless `--no-ledger` is passed (CLI). The row's tags carry `kind=image` +and the prompt digest. diff --git a/llm_connect/__init__.py b/llm_connect/__init__.py index 66b1cd9..e6c7e65 100644 --- a/llm_connect/__init__.py +++ b/llm_connect/__init__.py @@ -51,6 +51,16 @@ from llm_connect.grading import ( LLMJudge, PairedGrader, ) +from llm_connect.images import ( + ImageReceipt, + ImageRenderRequest, + ImageRenderResult, + ImageSpendGate, + ImageSpendPolicy, + OpenRouterImageClient, + create_image_client, + render_image, +) from llm_connect.models import BudgetTracker, LLMResponse, RunConfig from llm_connect.openai import OpenAIAdapter from llm_connect.openrouter import OpenRouterAdapter @@ -143,6 +153,14 @@ __all__ = [ "AdaptiveRoutingPolicy", "ShadowingAdapter", "LLMServer", + "ImageRenderRequest", + "ImageRenderResult", + "ImageReceipt", + "ImageSpendPolicy", + "ImageSpendGate", + "OpenRouterImageClient", + "create_image_client", + "render_image", "ModelRate", "ModelRateRegistry", "CostEstimate", diff --git a/llm_connect/cli.py b/llm_connect/cli.py index 79a076e..0a16e3d 100644 --- a/llm_connect/cli.py +++ b/llm_connect/cli.py @@ -15,9 +15,22 @@ from llm_connect.balance import ( get_account_balance, ) from llm_connect.costs import estimate_cost -from llm_connect.exceptions import LLMBalanceUnsupportedError, LLMConfigurationError, LLMError +from llm_connect.exceptions import ( + LLMBalanceUnsupportedError, + LLMBudgetExceededError, + LLMConfigurationError, + LLMError, +) from llm_connect.factory import create_adapter from llm_connect.fx import resolve_fx_rate +from llm_connect.images import ( + ImageRenderRequest, + ImageSpendGate, + ImageSpendPolicy, + create_image_client, + render_image, + usage_event_from_receipt, +) from llm_connect.models import RunConfig from llm_connect.problem_classes import ProblemClass, ProblemClassRegistry from llm_connect.quality import QualityLedger @@ -135,9 +148,163 @@ def _build_parser() -> argparse.ArgumentParser: ) balance.set_defaults(func=_balance_show) + image = commands.add_parser("image", help="Render a square text-to-image portrait") + image_commands = image.add_subparsers(dest="image_command", required=True) + image_render = image_commands.add_parser( + "render", + help="Render one prompt, or each JSON object in a batch file", + ) + image_render.add_argument("--prompt", default=None, help="Prompt text for a single render") + image_render.add_argument( + "--batch", + type=Path, + default=None, + help="JSONL file with prompt and output_path on each line", + ) + image_render.add_argument("--output", type=Path, default=None, help="File to write (.png, .jpg, .jpeg)") + image_render.add_argument("--stdout", action="store_true", help="Write image bytes to stdout") + image_render.add_argument("--provider", default="openrouter", help="Image provider (default openrouter)") + image_render.add_argument("--model", required=True, help="Image model id (required; no priced default)") + image_render.add_argument("--size", default="1024x1024", help="Square size, at least 1024x1024") + image_render.add_argument("--format", dest="output_format", default=None, help="png or jpeg") + image_render.add_argument("--max-cost-usd", type=float, default=None, help="Per-call USD cap") + image_render.add_argument("--max-batch-cost-usd", type=float, default=None, help="Batch USD cap") + image_render.add_argument( + "--quote-usd", + type=float, + default=None, + help="USD reserved before the call. Required when a cap is set", + ) + image_render.add_argument("--force", action="store_true", help="Replace an existing output file") + image_render.add_argument("--ledger", type=Path, default=None, help="Usage ledger JSONL path") + image_render.add_argument("--no-ledger", action="store_true", help="Do not append to the usage ledger") + image_render.add_argument("--eur-per-usd", type=float, default=None, help="Override FX rate") + image_render.add_argument("--json", action="store_true", help="Emit receipt JSON") + image_render.set_defaults(func=_image_render) + return parser +def _image_render(args: argparse.Namespace) -> int: + jobs = _image_jobs(args) + if args.stdout and len(jobs) != 1: + raise SystemExit("--stdout accepts a single --prompt render") + fx = resolve_fx_rate(args.eur_per_usd) + try: + policy = ImageSpendPolicy( + max_cost_usd=args.max_cost_usd, + max_batch_cost_usd=args.max_batch_cost_usd, + quote_usd=args.quote_usd, + ) + client = create_image_client(args.provider, model=args.model) + except LLMError as exc: + print(str(exc), file=sys.stderr) + return 1 + + gate = ImageSpendGate(policy) + receipts: list[dict[str, Any]] = [] + exit_code = 0 + for request in jobs: + try: + result = render_image(client, request, gate=gate, fx=fx, force=args.force) + except LLMBudgetExceededError as exc: + print(str(exc), file=sys.stderr) + exit_code = 2 + break + except LLMError as exc: + print(str(exc), file=sys.stderr) + exit_code = 1 + break + if not args.no_ledger: + ledger_path = args.ledger or default_usage_ledger_path() + UsageLedger(ledger_path).append(usage_event_from_receipt(result.receipt, source="cli")) + receipts.append(result.receipt.to_dict()) + if args.stdout: + sys.stdout.buffer.write(result.image) + if not result.receipt.within_cap: + print("Image cost exceeded the configured cap; batch stopped", file=sys.stderr) + exit_code = 2 + break + + indent = 2 if args.json else None + if receipts and not args.stdout: + payload: Any = receipts[0] if len(receipts) == 1 and args.batch is None else receipts + print(json.dumps(payload, indent=indent, sort_keys=True)) + elif receipts and args.stdout: + print(json.dumps(receipts[0], indent=indent, sort_keys=True), file=sys.stderr) + return exit_code + + +def _image_jobs(args: argparse.Namespace) -> list[ImageRenderRequest]: + if bool(args.prompt) == bool(args.batch): + raise SystemExit("Pass exactly one of --prompt or --batch") + if args.batch is not None: + if args.output is not None or args.stdout: + raise SystemExit("--batch reads output_path from each line; do not pass --output or --stdout") + return _load_image_batch(args.batch, default_size=args.size, default_format=args.output_format) + if args.stdout and args.output is not None: + raise SystemExit("Pass only one of --output or --stdout") + if not args.stdout and args.output is None: + raise SystemExit("Pass --output or --stdout") + output_format = args.output_format + if output_format is None: + output_format = "jpeg" if args.output is not None and args.output.suffix.lower() in {".jpg", ".jpeg"} else "png" + return [ + ImageRenderRequest( + prompt=args.prompt, + size=args.size, + output_format=output_format, + output_path=None if args.stdout else args.output, + ) + ] + + +def _load_image_batch( + path: Path, + *, + default_size: str, + default_format: str | None, +) -> list[ImageRenderRequest]: + jobs: list[ImageRenderRequest] = [] + try: + lines = path.read_text(encoding="utf-8").splitlines() + except OSError as exc: + raise SystemExit(f"Could not read batch file: {exc}") from exc + for line_number, line in enumerate(lines, start=1): + if not line.strip(): + continue + try: + item = json.loads(line) + except json.JSONDecodeError as exc: + raise SystemExit(f"{path}:{line_number}: invalid JSON: {exc}") from exc + if not isinstance(item, dict): + raise SystemExit(f"{path}:{line_number}: expected a JSON object") + prompt = item.get("prompt") + output = item.get("output_path") + if not isinstance(prompt, str) or not isinstance(output, str) or not output.strip(): + raise SystemExit(f"{path}:{line_number}: prompt and output_path are required") + output_path = Path(output) + output_format = item.get("output_format", default_format) + if output_format is None: + output_format = "jpeg" if output_path.suffix.lower() in {".jpg", ".jpeg"} else "png" + if not isinstance(output_format, str): + raise SystemExit(f"{path}:{line_number}: output_format must be a string") + size = item.get("size", default_size) + if not isinstance(size, str): + raise SystemExit(f"{path}:{line_number}: size must be a string") + jobs.append( + ImageRenderRequest( + prompt=prompt, + size=size, + output_format=output_format, + output_path=output_path, + ) + ) + if not jobs: + raise SystemExit(f"{path}: batch file has no renders") + return jobs + + def _rates_show(args: argparse.Namespace) -> int: registry = ModelRateRegistry.default() if args.rates: diff --git a/llm_connect/images.py b/llm_connect/images.py new file mode 100644 index 0000000..3441f24 --- /dev/null +++ b/llm_connect/images.py @@ -0,0 +1,564 @@ +"""Text-to-image rendering with a spend gate and a provenance receipt. + +This is a separate surface from :class:`~llm_connect.adapter.LLMAdapter`. +A render takes a prompt and returns image bytes plus a receipt that names +the provider, the model, a digest of the prompt, and the cost. Credentials +stay inside the client that talks to the provider. + +The first client speaks OpenRouter's ``POST /api/v1/images`` API. The model +is always chosen by the caller. A per-call or batch cap requires a quote so +the gate can refuse before the provider is contacted. +""" + +from __future__ import annotations + +import base64 +import hashlib +import re +import threading +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Protocol + +from llm_connect._http import post_json +from llm_connect.config import find_project_root, resolve_api_key +from llm_connect.exceptions import ( + LLMAPIError, + LLMBudgetExceededError, + LLMConfigurationError, +) +from llm_connect.fx import FxRate, resolve_fx_rate +from llm_connect.usage import UsageEvent + +_SIZE_RE = re.compile(r"^(\d+)x(\d+)$") +_MIN_EDGE = 1024 +_MAX_EDGE = 4096 +_MAX_PROMPT_CHARS = 16_000 +_MONEY_EPSILON = 1e-9 + +_MAGIC: dict[str, bytes] = { + "png": b"\x89PNG\r\n\x1a\n", + "jpeg": b"\xff\xd8\xff", +} +_MEDIA_TYPE = {"png": "image/png", "jpeg": "image/jpeg"} +_SUFFIX_FORMAT = {".png": "png", ".jpg": "jpeg", ".jpeg": "jpeg"} + +_IMAGE_PROVIDERS: dict[str, dict[str, str]] = { + "openrouter": { + "api_base": "https://openrouter.ai/api/v1", + "env_var": "OPENROUTER_API_KEY", + }, +} + + +class ImageClient(Protocol): + """Provider call used by :func:`render_image`. + + ``generate`` returns image bytes and the provider-reported USD cost. + ``None`` means the provider omitted a cost. + """ + + provider: str + model: str + + def generate(self, request: ImageRenderRequest) -> tuple[bytes, float | None]: + """Call the provider once. Do not write files or enforce spend caps.""" + + +@dataclass(frozen=True) +class ImageRenderRequest: + """One square text-to-image render. + + ``size`` is ``WIDTHxHEIGHT`` with equal edges of at least 1024. + ``output_format`` is ``png`` or ``jpeg``. + """ + + prompt: str + size: str = "1024x1024" + output_format: str = "png" + output_path: Path | None = None + + def edge(self) -> int: + """Return the square edge length after validation.""" + return parse_square_size(self.size) + + def format(self) -> str: + """Return ``png`` or ``jpeg`` after validation against ``output_path``.""" + return resolve_output_format(self.output_format, self.output_path) + + +@dataclass(frozen=True) +class ImageReceipt: + """Provenance for one render. Image bytes are not part of the receipt.""" + + provider: str + model: str + prompt_digest: str + cost_usd: float | None + cost_eur: float | None + cost_source: str + width: int + height: int + media_type: str + output_format: str + byte_length: int + output_path: str | None + within_cap: bool + fx_source: str | None = None + + def to_dict(self) -> dict[str, Any]: + """JSON-ready receipt. Suitable for the hall to store next to a portrait.""" + return { + "provider": self.provider, + "model": self.model, + "prompt_digest": self.prompt_digest, + "cost_usd": self.cost_usd, + "cost_eur": self.cost_eur, + "cost_source": self.cost_source, + "width": self.width, + "height": self.height, + "media_type": self.media_type, + "output_format": self.output_format, + "byte_length": self.byte_length, + "output_path": self.output_path, + "within_cap": self.within_cap, + "fx_source": self.fx_source, + } + + +@dataclass(frozen=True) +class ImageRenderResult: + """Image bytes plus the receipt that describes them.""" + + image: bytes + receipt: ImageReceipt + + +@dataclass(frozen=True) +class ImageSpendPolicy: + """Caps applied before a provider call. + + When either cap is set, ``quote_usd`` is required. The quote is the + most the gate is willing to reserve for the call. The provider-reported + cost, when present, is what the receipt and the running total record. + """ + + max_cost_usd: float | None = None + max_batch_cost_usd: float | None = None + quote_usd: float | None = None + + def __post_init__(self) -> None: + for name in ("max_cost_usd", "max_batch_cost_usd", "quote_usd"): + value = getattr(self, name) + if value is not None: + object.__setattr__(self, name, _non_negative_money(name, value)) + capped = self.max_cost_usd is not None or self.max_batch_cost_usd is not None + if capped and self.quote_usd is None: + raise LLMConfigurationError( + "quote_usd is required when a per-call or batch cost cap is set", + context={"max_cost_usd": self.max_cost_usd, "max_batch_cost_usd": self.max_batch_cost_usd}, + ) + if ( + self.quote_usd is not None + and self.max_cost_usd is not None + and self.quote_usd > self.max_cost_usd + _MONEY_EPSILON + ): + raise LLMBudgetExceededError( + "Image quote exceeds the per-call cost cap", + context={"quote_usd": self.quote_usd, "max_cost_usd": self.max_cost_usd}, + ) + if ( + self.quote_usd is not None + and self.max_batch_cost_usd is not None + and self.quote_usd > self.max_batch_cost_usd + _MONEY_EPSILON + ): + raise LLMBudgetExceededError( + "Image quote exceeds the batch cost cap", + context={ + "quote_usd": self.quote_usd, + "max_batch_cost_usd": self.max_batch_cost_usd, + }, + ) + + +class ImageSpendGate: + """Reserve a quote before the provider call and commit the actual cost after. + + Concurrent callers share one gate. A reservation counts against the batch + cap until :meth:`commit` or :meth:`release`. + """ + + def __init__(self, policy: ImageSpendPolicy | None = None) -> None: + self.policy = policy or ImageSpendPolicy() + self.spent_usd = 0.0 + self._reserved_usd = 0.0 + self._lock = threading.Lock() + + def reserve(self) -> None: + """Hold this call's quote against the batch cap, or refuse first.""" + quote = self.policy.quote_usd or 0.0 + with self._lock: + projected = self.spent_usd + self._reserved_usd + quote + batch = self.policy.max_batch_cost_usd + if batch is not None and projected > batch + _MONEY_EPSILON: + raise LLMBudgetExceededError( + "Image batch cost cap would be exceeded", + context={ + "spent_usd": self.spent_usd, + "reserved_usd": self._reserved_usd, + "quote_usd": self.policy.quote_usd, + "max_batch_cost_usd": batch, + }, + ) + per_call = self.policy.max_cost_usd + if ( + per_call is not None + and self.policy.quote_usd is not None + and self.policy.quote_usd > per_call + _MONEY_EPSILON + ): + raise LLMBudgetExceededError( + "Image quote exceeds the per-call cost cap", + context={"quote_usd": self.policy.quote_usd, "max_cost_usd": per_call}, + ) + self._reserved_usd += quote + + def release(self) -> None: + """Drop a reservation when the provider call fails.""" + quote = self.policy.quote_usd or 0.0 + with self._lock: + self._reserved_usd = max(0.0, self._reserved_usd - quote) + + def commit(self, accounted_usd: float) -> bool: + """Replace the reservation with the accounted cost. + + Returns ``False`` when the accounted cost is over a configured cap. + The cost is still counted, because the provider already charged it. + """ + accounted = _non_negative_money("accounted_usd", accounted_usd) + quote = self.policy.quote_usd or 0.0 + with self._lock: + self._reserved_usd = max(0.0, self._reserved_usd - quote) + self.spent_usd += accounted + within = True + per_call = self.policy.max_cost_usd + if per_call is not None and accounted > per_call + _MONEY_EPSILON: + within = False + batch = self.policy.max_batch_cost_usd + if batch is not None and self.spent_usd > batch + _MONEY_EPSILON: + within = False + return within + + +def prompt_digest(prompt: str) -> str: + """SHA-256 hex digest of the prompt's UTF-8 bytes.""" + return hashlib.sha256(prompt.encode("utf-8")).hexdigest() + + +def parse_square_size(size: str) -> int: + """Accept ``1024x1024`` and larger square sizes up to 4096.""" + match = _SIZE_RE.fullmatch(size.strip()) + if match is None: + raise LLMConfigurationError( + "Image size must look like 1024x1024", + context={"size": size}, + ) + width = int(match.group(1)) + height = int(match.group(2)) + if width != height or width < _MIN_EDGE or width > _MAX_EDGE: + raise LLMConfigurationError( + f"Image size must be square and between {_MIN_EDGE} and {_MAX_EDGE} pixels", + context={"size": size}, + ) + return width + + +def resolve_output_format(output_format: str, output_path: Path | None) -> str: + """Return ``png`` or ``jpeg``, and require the path suffix to agree.""" + fmt = output_format.strip().lower() + if fmt == "jpg": + fmt = "jpeg" + if fmt not in _MAGIC: + raise LLMConfigurationError( + "Image output_format must be png or jpeg", + context={"output_format": output_format}, + ) + if output_path is not None: + suffix = output_path.suffix.lower() + expected = _SUFFIX_FORMAT.get(suffix) + if expected is None: + raise LLMConfigurationError( + "Image output path must end in .png, .jpg, or .jpeg", + context={"output_path": str(output_path)}, + ) + if expected != fmt: + raise LLMConfigurationError( + "Image output path suffix does not match output_format", + context={"output_path": str(output_path), "output_format": fmt}, + ) + return fmt + + +def render_image( + client: ImageClient, + request: ImageRenderRequest, + *, + gate: ImageSpendGate | None = None, + fx: FxRate | float | None = None, + force: bool = False, +) -> ImageRenderResult: + """Render one image and, when requested, write it to ``output_path``. + + The provider is not called when the spend gate refuses the quote. + A provider HTTP failure releases the reservation. A successful response + is counted even when the bytes are rejected or the file cannot be written. + """ + _validate_prompt(request.prompt) + edge = request.edge() + fmt = request.format() + if request.output_path is not None and request.output_path.exists() and not force: + raise LLMConfigurationError( + "Image output already exists", + context={"output_path": str(request.output_path)}, + ) + spend = gate or ImageSpendGate() + rate = resolve_fx_rate(fx) + + spend.reserve() + try: + raw, provider_cost = client.generate(request) + except Exception: + spend.release() + raise + + cost_usd, cost_source = _accounted_cost(provider_cost, spend.policy.quote_usd) + within_cap = spend.commit(0.0 if cost_usd is None else cost_usd) + if not raw.startswith(_MAGIC[fmt]): + raise LLMAPIError( + f"Provider returned bytes that are not {fmt}", + context={ + "provider": client.provider, + "model": client.model, + "output_format": fmt, + "cost_usd": cost_usd, + "cost_source": cost_source, + "within_cap": within_cap, + }, + ) + + output_path: str | None = None + if request.output_path is not None: + write_image_file(request.output_path, raw, force=force) + output_path = str(request.output_path) + + cost_eur = None if cost_usd is None or rate is None else rate.usd_to_eur(cost_usd) + receipt = ImageReceipt( + provider=client.provider, + model=client.model, + prompt_digest=prompt_digest(request.prompt), + cost_usd=cost_usd, + cost_eur=cost_eur, + cost_source=cost_source, + width=edge, + height=edge, + media_type=_MEDIA_TYPE[fmt], + output_format=fmt, + byte_length=len(raw), + output_path=output_path, + within_cap=within_cap, + fx_source=None if rate is None else rate.source, + ) + return ImageRenderResult(image=raw, receipt=receipt) + + +def write_image_file(path: Path, data: bytes, *, force: bool = False) -> None: + """Write image bytes atomically. Refuse to replace an existing file unless forced.""" + if path.exists() and not force: + raise LLMConfigurationError( + "Image output already exists", + context={"output_path": str(path)}, + ) + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(path.name + ".partial") + try: + temporary.write_bytes(data) + temporary.replace(path) + except Exception: + temporary.unlink(missing_ok=True) + raise + + +def usage_event_from_receipt(receipt: ImageReceipt, *, source: str) -> UsageEvent: + """Ledger row for one image render. The prompt itself is not stored.""" + return UsageEvent( + provider=receipt.provider, + model_id=receipt.model, + prompt_tokens=0, + completion_tokens=0, + total_tokens=0, + cost_usd=receipt.cost_usd, + cost_eur=receipt.cost_eur, + cost_source=receipt.cost_source, + source=source, + fx_source=receipt.fx_source, + tags={ + "kind": "image", + "prompt_digest": receipt.prompt_digest, + "width": receipt.width, + "height": receipt.height, + "output_format": receipt.output_format, + "within_cap": receipt.within_cap, + }, + ) + + +def create_image_client( + provider: str = "openrouter", + model: str | None = None, + api_key: str | None = None, + **kwargs: Any, +) -> ImageClient: + """Build an image client. ``model`` is required; there is no priced default.""" + if provider not in _IMAGE_PROVIDERS: + known = ", ".join(sorted(_IMAGE_PROVIDERS)) + raise LLMConfigurationError( + f"Unknown image provider {provider!r}. Choose from: {known}", + context={"provider": provider}, + ) + if model is None or not str(model).strip(): + raise LLMConfigurationError( + "An image model is required", + context={"provider": provider}, + ) + if provider == "openrouter": + return OpenRouterImageClient(model=model, api_key=api_key, **kwargs) + raise LLMConfigurationError( + f"Image provider {provider!r} is not implemented", + context={"provider": provider}, + ) + + +class OpenRouterImageClient: + """OpenRouter ``POST /images`` client. The API key never appears on a receipt.""" + + def __init__( + self, + model: str, + api_key: str | None = None, + api_base: str | None = None, + timeout: int = 300, + ) -> None: + defaults = _IMAGE_PROVIDERS["openrouter"] + self.provider = "openrouter" + self.model = model.strip() + if not self.model: + raise LLMConfigurationError("An image model is required", context={"provider": "openrouter"}) + self._api_base = (api_base or defaults["api_base"]).rstrip("/") + self._timeout = timeout + root = find_project_root() + key_files = [root / "apikey-openrouter.txt"] if root else [] + self._api_key = resolve_api_key( + explicit=api_key, + env_var=defaults["env_var"], + key_file_paths=key_files, + ) + + def generate(self, request: ImageRenderRequest) -> tuple[bytes, float | None]: + """Generate one image. Returns raw bytes and the USD cost, if reported.""" + if not self._api_key: + raise LLMConfigurationError( + "No API key configured for image rendering", + context={"provider": self.provider, "env_var": "OPENROUTER_API_KEY"}, + ) + fmt = request.format() + edge = request.edge() + payload: dict[str, Any] = { + "model": self.model, + "prompt": request.prompt, + "n": 1, + "aspect_ratio": "1:1", + "size": f"{edge}x{edge}", + "output_format": fmt, + } + data = post_json( + f"{self._api_base}/images", + payload, + headers={"Authorization": f"Bearer {self._api_key}"}, + timeout=self._timeout, + ) + return _decode_openrouter_image(data), _provider_cost(data) + + +def _validate_prompt(prompt: str) -> None: + if not isinstance(prompt, str) or not prompt.strip(): + raise LLMConfigurationError("Image prompt must be a non-empty string") + if len(prompt) > _MAX_PROMPT_CHARS: + raise LLMConfigurationError( + "Image prompt is too long", + context={"length": len(prompt), "max_chars": _MAX_PROMPT_CHARS}, + ) + + +def _accounted_cost( + provider_cost: float | None, + quote_usd: float | None, +) -> tuple[float | None, str]: + if provider_cost is not None: + return _non_negative_money("cost_usd", provider_cost), "provider" + if quote_usd is not None: + return quote_usd, "quote" + return None, "unpriced" + + +def _provider_cost(data: dict[str, Any]) -> float | None: + usage = data.get("usage") + if not isinstance(usage, dict) or "cost" not in usage or usage["cost"] is None: + return None + return _non_negative_money("cost_usd", usage["cost"]) + + +def _decode_openrouter_image(data: dict[str, Any]) -> bytes: + items = data.get("data") + if not isinstance(items, list) or not items: + raise LLMAPIError("Image response did not include data", context={"provider": "openrouter"}) + first = items[0] + if not isinstance(first, dict): + raise LLMAPIError("Image response data[0] was not an object", context={"provider": "openrouter"}) + encoded = first.get("b64_json") + if not isinstance(encoded, str) or not encoded.strip(): + raise LLMAPIError( + "Image response did not include b64_json", + context={"provider": "openrouter"}, + ) + payload = encoded.strip() + if payload.startswith("data:") and "," in payload: + payload = payload.split(",", 1)[1] + try: + raw = base64.b64decode(payload, validate=True) + except Exception as exc: + raise LLMAPIError( + "Image response b64_json could not be decoded", + cause=exc, + context={"provider": "openrouter"}, + ) from exc + if not raw: + raise LLMAPIError("Image response decoded to empty bytes", context={"provider": "openrouter"}) + return raw + + +def _non_negative_money(name: str, value: Any) -> float: + try: + amount = float(value) + except (TypeError, ValueError) as exc: + raise LLMConfigurationError( + f"{name} must be a number", + cause=exc, + context={name: value}, + ) from exc + if amount < 0 or amount != amount: # NaN + raise LLMConfigurationError( + f"{name} must be a non-negative number", + context={name: value}, + ) + return amount + + diff --git a/llm_connect/server.py b/llm_connect/server.py index 31061d5..37cbad2 100644 --- a/llm_connect/server.py +++ b/llm_connect/server.py @@ -3,6 +3,7 @@ Minimal HTTP server for llm_connect — serve mode (FR-1). Exposes: POST /execute — run a prompt through the configured adapter + POST /images — render one square image when an image client is configured GET /health — liveness probe Usage (programmatic):: @@ -21,6 +22,7 @@ Usage (CLI):: """ import argparse +import base64 import datetime as _dt import json import os @@ -43,13 +45,22 @@ from llm_connect.exceptions import ( LLMRateLimitError, LLMTimeoutError, ) +from llm_connect.images import ( + ImageClient, + ImageRenderRequest, + ImageSpendGate, + render_image, + usage_event_from_receipt, +) from llm_connect.models import LLMResponse, RunConfig from llm_connect.profiles import ProfiledLLMAdapter, default_runtime_profiles -from llm_connect.usage import maybe_record_usage, suppress_auto_usage_record +from llm_connect.usage import UsageLedger, maybe_record_usage, suppress_auto_usage_record class _AdapterHTTPServer(ThreadingHTTPServer): adapter: LLMAdapter + image_client: ImageClient | None + image_gate: ImageSpendGate | None class _Handler(BaseHTTPRequestHandler): @@ -73,6 +84,9 @@ class _Handler(BaseHTTPRequestHandler): def do_POST(self) -> None: parsed = urlsplit(self.path) + if parsed.path == "/images": + self._post_image() + return if parsed.path != "/execute": self._respond(404, {"error": "not found"}) return @@ -132,6 +146,52 @@ class _Handler(BaseHTTPRequestHandler): status, body = _error_response(exc) self._respond(status, body) + def _post_image(self) -> None: + client = self.server.image_client + gate = self.server.image_gate + if client is None or gate is None: + self._respond(404, {"error": "not found"}) + return + length = int(self.headers.get("Content-Length", 0)) + raw = self.rfile.read(length) + try: + data = json.loads(raw) + except (json.JSONDecodeError, ValueError): + self._respond(400, {"error": "invalid JSON body"}) + return + if not isinstance(data, dict): + self._respond(400, {"error": "JSON body must be an object"}) + return + prompt = data.get("prompt") + if not isinstance(prompt, str) or not prompt.strip(): + self._respond(400, {"error": "missing required field: 'prompt'"}) + return + size = data.get("size", "1024x1024") + output_format = data.get("output_format", "png") + if not isinstance(size, str) or not isinstance(output_format, str): + self._respond(400, {"error": "size and output_format must be strings"}) + return + try: + result = render_image( + client, + ImageRenderRequest(prompt=prompt, size=size, output_format=output_format), + gate=gate, + ) + except Exception as exc: + status, body = _error_response(exc) + self._respond(status, body) + return + ledger_path = os.environ.get("LLM_CONNECT_USAGE_LEDGER") + if ledger_path: + UsageLedger(Path(ledger_path)).append( + usage_event_from_receipt(result.receipt, source="server") + ) + body = { + "receipt": result.receipt.to_dict(), + "image_base64": base64.b64encode(result.image).decode("ascii"), + } + self._respond(200, body) + # ── helpers ──────────────────────────────────────────────────── def _respond(self, status: int, body: dict) -> None: @@ -157,9 +217,13 @@ class LLMServer: adapter: LLMAdapter, host: str = "127.0.0.1", port: int = 8080, + image_client: ImageClient | None = None, + image_gate: ImageSpendGate | None = None, ) -> None: self._httpd = _AdapterHTTPServer((host, port), _Handler) self._httpd.adapter = adapter + self._httpd.image_client = image_client + self._httpd.image_gate = image_gate self._thread: threading.Thread | None = None @property @@ -364,6 +428,25 @@ def main(argv: list[str] | None = None) -> None: default=_truthy(os.environ.get("LLM_CONNECT_STRICT_PROFILES", "")), help="Reject non-profile model_name values instead of passing them through.", ) + parser.add_argument( + "--image-model", + default=os.environ.get("LLM_CONNECT_IMAGE_MODEL") or None, + help="Enable POST /images for this model. Requires per-call cap and quote.", + ) + parser.add_argument("--image-provider", default="openrouter", help="Image provider (default openrouter)") + parser.add_argument("--image-max-cost-usd", type=float, default=None, help="Per-call USD cap for /images") + parser.add_argument( + "--image-max-batch-cost-usd", + type=float, + default=None, + help="Process-lifetime USD cap for /images", + ) + parser.add_argument( + "--image-quote-usd", + type=float, + default=None, + help="USD reserved before each /images call", + ) args = parser.parse_args(argv) adapter = _build_adapter( @@ -372,7 +455,28 @@ def main(argv: list[str] | None = None) -> None: enable_profiles=not args.disable_profiles, strict_profiles=args.strict_profiles, ) - server = LLMServer(adapter=adapter, host=args.host, port=args.port) + image_client = None + image_gate = None + if args.image_model: + if args.image_max_cost_usd is None or args.image_quote_usd is None: + parser.error("--image-model requires --image-max-cost-usd and --image-quote-usd") + from llm_connect.images import ImageSpendPolicy, create_image_client + + image_client = create_image_client(args.image_provider, model=args.image_model) + image_gate = ImageSpendGate( + ImageSpendPolicy( + max_cost_usd=args.image_max_cost_usd, + max_batch_cost_usd=args.image_max_batch_cost_usd, + quote_usd=args.image_quote_usd, + ) + ) + server = LLMServer( + adapter=adapter, + host=args.host, + port=args.port, + image_client=image_client, + image_gate=image_gate, + ) print(f"llm_connect server listening on http://{args.host}:{args.port}") try: server.serve_forever() diff --git a/tests/test_images.py b/tests/test_images.py new file mode 100644 index 0000000..f5395c2 --- /dev/null +++ b/tests/test_images.py @@ -0,0 +1,250 @@ +"""Text-to-image rendering, spend gate, and OpenRouter payload parsing.""" + +import base64 +import json + +import pytest + +from llm_connect.cli import main +from llm_connect.exceptions import LLMAPIError, LLMBudgetExceededError, LLMConfigurationError +from llm_connect.images import ( + ImageRenderRequest, + ImageSpendGate, + ImageSpendPolicy, + OpenRouterImageClient, + create_image_client, + prompt_digest, + render_image, +) +from llm_connect.usage import UsageLedger + +_PNG = b"\x89PNG\r\n\x1a\nrest" +_JPEG = b"\xff\xd8\xff\xd9" + + +class FakeImageClient: + def __init__(self, payload: bytes = _PNG, cost: float | None = 0.02) -> None: + self.provider = "openrouter" + self.model = "test/image" + self.payload = payload + self.cost = cost + self.calls: list[ImageRenderRequest] = [] + + def generate(self, request: ImageRenderRequest) -> tuple[bytes, float | None]: + self.calls.append(request) + return self.payload, self.cost + + +def test_square_size_rejects_small_and_non_square() -> None: + client = FakeImageClient() + with pytest.raises(LLMConfigurationError): + render_image(client, ImageRenderRequest(prompt="a gear", size="512x512")) + with pytest.raises(LLMConfigurationError): + render_image(client, ImageRenderRequest(prompt="a gear", size="1024x768")) + assert client.calls == [] + + +def test_render_writes_jpeg_and_receipt_omits_prompt_and_credentials(tmp_path) -> None: + client = FakeImageClient(payload=_JPEG, cost=0.04) + output = tmp_path / "visuals" / "ada.jpg" + result = render_image( + client, + ImageRenderRequest( + prompt="precise technical portrait of a gear", + output_format="jpeg", + output_path=output, + ), + gate=ImageSpendGate(ImageSpendPolicy(max_cost_usd=0.10, quote_usd=0.05)), + fx=0.5, + ) + + assert output.read_bytes() == _JPEG + assert result.receipt.provider == "openrouter" + assert result.receipt.model == "test/image" + assert result.receipt.prompt_digest == prompt_digest("precise technical portrait of a gear") + assert result.receipt.cost_usd == 0.04 + assert result.receipt.cost_eur == pytest.approx(0.02) + assert result.receipt.cost_source == "provider" + assert result.receipt.width == 1024 + assert result.receipt.height == 1024 + assert result.receipt.media_type == "image/jpeg" + assert result.receipt.within_cap is True + encoded = json.dumps(result.receipt.to_dict()) + assert "precise technical portrait" not in encoded + assert "api_key" not in encoded + assert "Bearer" not in encoded + + +def test_batch_cap_stops_before_the_provider_call() -> None: + client = FakeImageClient(cost=0.05) + gate = ImageSpendGate( + ImageSpendPolicy(max_cost_usd=0.05, max_batch_cost_usd=0.10, quote_usd=0.05) + ) + request = ImageRenderRequest(prompt="one") + render_image(client, request, gate=gate) + render_image(client, ImageRenderRequest(prompt="two"), gate=gate) + with pytest.raises(LLMBudgetExceededError): + render_image(client, ImageRenderRequest(prompt="three"), gate=gate) + assert len(client.calls) == 2 + assert gate.spent_usd == pytest.approx(0.10) + + +def test_cap_requires_a_quote_and_quote_cannot_exceed_it() -> None: + with pytest.raises(LLMConfigurationError): + ImageSpendPolicy(max_cost_usd=0.10) + with pytest.raises(LLMBudgetExceededError): + ImageSpendPolicy(max_cost_usd=0.10, quote_usd=0.20) + + +def test_existing_output_is_left_untouched(tmp_path) -> None: + output = tmp_path / "ada.png" + output.write_bytes(b"keep") + client = FakeImageClient() + with pytest.raises(LLMConfigurationError): + render_image( + client, + ImageRenderRequest(prompt="a gear", output_path=output), + ) + assert output.read_bytes() == b"keep" + assert client.calls == [] + + +def test_quote_is_used_when_the_provider_omits_cost() -> None: + client = FakeImageClient(cost=None) + result = render_image( + client, + ImageRenderRequest(prompt="a gear"), + gate=ImageSpendGate(ImageSpendPolicy(max_batch_cost_usd=1.0, quote_usd=0.03)), + ) + assert result.receipt.cost_usd == 0.03 + assert result.receipt.cost_source == "quote" + + +def test_openrouter_client_posts_square_size_and_decodes_b64(monkeypatch) -> None: + seen: dict = {} + + def fake_post(url, payload, headers, timeout=300): + seen["url"] = url + seen["payload"] = payload + seen["headers"] = headers + seen["timeout"] = timeout + encoded = base64.b64encode(_PNG).decode("ascii") + return {"data": [{"b64_json": encoded}], "usage": {"cost": 0.07}} + + monkeypatch.setattr("llm_connect.images.post_json", fake_post) + client = OpenRouterImageClient(model="black-forest-labs/flux-3-image", api_key="test-key") + raw, cost = client.generate( + ImageRenderRequest(prompt="a gear", size="1024x1024", output_format="png") + ) + + assert raw == _PNG + assert cost == 0.07 + assert seen["url"] == "https://openrouter.ai/api/v1/images" + assert seen["payload"]["model"] == "black-forest-labs/flux-3-image" + assert seen["payload"]["aspect_ratio"] == "1:1" + assert seen["payload"]["size"] == "1024x1024" + assert seen["payload"]["n"] == 1 + assert seen["payload"]["output_format"] == "png" + assert seen["headers"]["Authorization"] == "Bearer test-key" + + +def test_openrouter_client_rejects_a_response_without_bytes(monkeypatch) -> None: + monkeypatch.setattr( + "llm_connect.images.post_json", + lambda *args, **kwargs: {"data": [{"url": "https://example.invalid/secret.png"}]}, + ) + client = OpenRouterImageClient(model="test/image", api_key="test-key") + with pytest.raises(LLMAPIError, match="b64_json"): + client.generate(ImageRenderRequest(prompt="a gear")) + + +def test_unknown_provider_and_missing_model_fail_closed(monkeypatch) -> None: + with pytest.raises(LLMConfigurationError): + create_image_client("openai", model="gpt-image-1") + with pytest.raises(LLMConfigurationError): + create_image_client("openrouter", model=None) + monkeypatch.setattr("llm_connect.images.resolve_api_key", lambda **kwargs: None) + with pytest.raises(LLMConfigurationError, match="No API key"): + OpenRouterImageClient(model="test/image").generate(ImageRenderRequest(prompt="a gear")) + + +def test_cli_render_writes_receipt_and_ledger(tmp_path, monkeypatch, capsys) -> None: + client = FakeImageClient(payload=_JPEG, cost=0.04) + monkeypatch.setattr("llm_connect.cli.create_image_client", lambda *args, **kwargs: client) + output = tmp_path / "ada.jpg" + ledger = tmp_path / "usage.jsonl" + code = main( + [ + "image", + "render", + "--prompt", + "a gear", + "--model", + "test/image", + "--output", + str(output), + "--max-cost-usd", + "0.10", + "--quote-usd", + "0.05", + "--ledger", + str(ledger), + "--json", + "--eur-per-usd", + "0.5", + ] + ) + + assert code == 0 + assert output.read_bytes() == _JPEG + receipt = json.loads(capsys.readouterr().out) + assert receipt["model"] == "test/image" + assert receipt["prompt_digest"] == prompt_digest("a gear") + assert receipt["cost_usd"] == 0.04 + assert receipt["output_path"] == str(output) + events = UsageLedger(ledger).read_all() + assert len(events) == 1 + assert events[0].tags["kind"] == "image" + assert events[0].tags["prompt_digest"] == receipt["prompt_digest"] + assert "a gear" not in json.dumps(events[0].to_dict()) + + +def test_cli_batch_stops_when_the_next_quote_does_not_fit(tmp_path, monkeypatch, capsys) -> None: + client = FakeImageClient(payload=_JPEG, cost=0.05) + monkeypatch.setattr("llm_connect.cli.create_image_client", lambda *args, **kwargs: client) + batch = tmp_path / "batch.jsonl" + first = tmp_path / "one.jpg" + second = tmp_path / "two.jpg" + third = tmp_path / "three.jpg" + lines = [ + {"prompt": "one", "output_path": str(first)}, + {"prompt": "two", "output_path": str(second)}, + {"prompt": "three", "output_path": str(third)}, + ] + batch.write_text("\n".join(json.dumps(line) for line in lines), encoding="utf-8") + code = main( + [ + "image", + "render", + "--batch", + str(batch), + "--model", + "test/image", + "--max-cost-usd", + "0.05", + "--max-batch-cost-usd", + "0.10", + "--quote-usd", + "0.05", + "--no-ledger", + ] + ) + + captured = capsys.readouterr() + assert code == 2 + assert first.is_file() + assert second.is_file() + assert not third.exists() + receipts = json.loads(captured.out) + assert [item["output_path"] for item in receipts] == [str(first), str(second)] + assert "batch cost cap" in captured.err diff --git a/tests/test_package_exports.py b/tests/test_package_exports.py index 92c582f..66cd4e5 100644 --- a/tests/test_package_exports.py +++ b/tests/test_package_exports.py @@ -63,6 +63,23 @@ def test_wp_0006_profile_primitives_are_exported_from_package_root(): assert name in llm_connect.__all__ +def test_image_render_primitives_are_exported_from_package_root(): + expected_names = [ + "ImageRenderRequest", + "ImageRenderResult", + "ImageReceipt", + "ImageSpendPolicy", + "ImageSpendGate", + "OpenRouterImageClient", + "create_image_client", + "render_image", + ] + + for name in expected_names: + assert hasattr(llm_connect, name) + assert name in llm_connect.__all__ + + def test_wp_0007_spend_primitives_are_exported_from_package_root(): expected_names = [ "FxRate", diff --git a/tests/test_server.py b/tests/test_server.py index 408f20c..8fc3035 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -2,6 +2,7 @@ Tests for LLMServer HTTP serve mode (FR-1). """ +import base64 import json import threading import time @@ -18,6 +19,7 @@ from llm_connect._diagnostics import ( ) from llm_connect.adapter import ErrorLLMAdapter, MockLLMAdapter from llm_connect.exceptions import LLMAPIError, LLMConfigurationError, LLMTimeoutError +from llm_connect.images import ImageSpendGate, ImageSpendPolicy from llm_connect.models import LLMResponse, RunConfig from llm_connect.profiles import CUSTODIAN_TRIAGE_BALANCED, ProfiledLLMAdapter, RuntimeProfile from llm_connect.server import LLMServer @@ -138,6 +140,14 @@ class TestExecute: assert status == 400 assert body == {"error": "invalid JSON body"} + def test_images_route_is_absent_until_configured(self, server): + status, body = _post( + f"http://127.0.0.1:{server.port}/images", + {"prompt": "a gear"}, + ) + assert status == 404 + assert body == {"error": "not found"} + def test_unknown_post_path_returns_404(self, server): status, body = _post( f"http://127.0.0.1:{server.port}/wrong", @@ -411,3 +421,43 @@ class TestExecute: assert [status for status, _body in results] == [200, 200] assert elapsed < 1.5 + + +class _ServerImageClient: + provider = "openrouter" + model = "test/image" + + def generate(self, request): + return b"\x89PNG\r\n\x1a\nserver", 0.02 + + +def test_images_endpoint_returns_receipt_and_png_bytes(): + gate = ImageSpendGate(ImageSpendPolicy(max_cost_usd=0.10, quote_usd=0.05)) + server = LLMServer( + adapter=MockLLMAdapter(mock_response="unused"), + port=0, + image_client=_ServerImageClient(), + image_gate=gate, + ) + server.start() + try: + status, body = _post( + f"http://127.0.0.1:{server.port}/images", + {"prompt": "a gear", "size": "1024x1024", "output_format": "png"}, + ) + execute_status, execute_body = _post( + f"http://127.0.0.1:{server.port}/execute", + {"prompt": "still here"}, + ) + finally: + server.stop() + + assert status == 200 + assert body["receipt"]["provider"] == "openrouter" + assert body["receipt"]["model"] == "test/image" + assert body["receipt"]["width"] == 1024 + assert body["receipt"]["within_cap"] is True + assert "prompt" not in body["receipt"] + assert base64.b64decode(body["image_base64"]).startswith(b"\x89PNG\r\n\x1a\n") + assert execute_status == 200 + assert execute_body["content"] == "unused" diff --git a/workplans/LLM-WP-0010-text-to-image-rendering.md b/workplans/LLM-WP-0010-text-to-image-rendering.md new file mode 100644 index 0000000..985144e --- /dev/null +++ b/workplans/LLM-WP-0010-text-to-image-rendering.md @@ -0,0 +1,73 @@ +--- +id: LLM-WP-0010 +type: workplan +title: "Text-to-image rendering for square portraits" +domain: agents +repo: llm-connect +status: active +flavor: implementation +owner: grok +topic_slug: text-to-image-rendering +created: "2026-10-08" +updated: "2026-10-08" +related: + - message:ae2b441f-7c38-4cb5-8572-47791cb52456 +--- + +# LLM-WP-0010 — Text-to-image rendering for square portraits + +Founder-approved request from fluid-telegram (2026-10-08) for the Hall of +Helix portrait backfill. This workplan adds the facility. It does not choose +the production model or the live budget, and it does not render the hall. + +## Library, OpenRouter client, and spend gate + +```task +id: LLM-WP-0010-T01 +status: done +priority: high +``` + +`llm_connect.images` renders one square image of at least 1024 pixels and +returns bytes plus a receipt: provider, model, SHA-256 prompt digest, and +cost. A per-call or batch cap requires a quote and refuses before the +provider call. OpenRouter `POST /api/v1/images` is the only client. The model +is required. The API key is resolved the same way as the chat adapter and is +not copied onto the receipt. Contract: `contracts/functional/images.md`. + +## CLI + +```task +id: LLM-WP-0010-T02 +status: done +priority: high +``` + +`llm-connect image render` writes `--output` or a JSONL `--batch` of +`prompt` / `output_path` lines. Receipts go to stdout. Exit 2 stops a batch +on a cap. Existing files are not replaced unless `--force`. + +## HTTP + +```task +id: LLM-WP-0010-T03 +status: done +priority: high +``` + +`POST /images` is registered only when `python -m llm_connect.server` is +started with `--image-model`, `--image-max-cost-usd`, and `--image-quote-usd`. +The response is the receipt plus base64 bytes. `/execute` is unchanged. +The process-lifetime batch cap is shared by concurrent requests. + +## Live backfill + +```task +id: LLM-WP-0010-T04 +status: wait +priority: medium +blocking_reason: "The facility is in place and covered by fake-provider tests. A paid Hall of Helix backfill still needs the founder to name the image model, the per-call quote, and the batch cap. fluid-telegram asked not to have that chosen for them." +``` + +Do not run a paid render, deploy a new image, or write hall portraits until +that choice is recorded. Reply to fluid-telegram used this workplan id.