Add square text-to-image rendering with a spend gate
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Has been cancelled

Hall of Helix needs a credential-safe render that returns a provenance
receipt and refuses a batch before the quote exceeds its cap. OpenRouter
is the only client; the model and the live budget stay with the founder.

Assistant: grok
Assistant-Session: 01a11ab6-1534-7d63-8606-662b92a80ed6
This commit is contained in:
tegwick 2026-10-08 11:01:49 +02:00
parent 9dc6b39766
commit e3ab268d6e
11 changed files with 1335 additions and 3 deletions

View file

@ -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 "<image-model>" \
--output visuals/gear.jpg \
--max-cost-usd 0.15 \
--quote-usd 0.08
```
Usage events append to a JSONL ledger (default

View file

@ -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.

View file

@ -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 "<image-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 "<image-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.

View file

@ -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",

View file

@ -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:

564
llm_connect/images.py Normal file
View file

@ -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

View file

@ -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()

250
tests/test_images.py Normal file
View file

@ -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

View file

@ -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",

View file

@ -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"

View file

@ -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.