Add square text-to-image rendering with a spend gate
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:
parent
9dc6b39766
commit
e3ab268d6e
11 changed files with 1335 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
3
SCOPE.md
3
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.
|
||||
|
|
|
|||
77
contracts/functional/images.md
Normal file
77
contracts/functional/images.md
Normal 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.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
564
llm_connect/images.py
Normal 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
|
||||
|
||||
|
||||
|
|
@ -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
250
tests/test_images.py
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
73
workplans/LLM-WP-0010-text-to-image-rendering.md
Normal file
73
workplans/LLM-WP-0010-text-to-image-rendering.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue