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 # current default provider
|
||||||
llm-connect balance --provider openrouter # one-shot backend select
|
llm-connect balance --provider openrouter # one-shot backend select
|
||||||
llm-connect balance --json
|
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
|
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`,
|
- Embedding adapters including `EmbeddingAdapter`,
|
||||||
`OpenAICompatibleEmbeddingAdapter`, `EmbeddingCache`, and
|
`OpenAICompatibleEmbeddingAdapter`, `EmbeddingCache`, and
|
||||||
`create_embedding_adapter`.
|
`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`.
|
- TOML-based configuration resolution via `toml_config.py` and `config.py`.
|
||||||
- Shared HTTP utilities, token estimation, similarity helpers, and the
|
- Shared HTTP utilities, token estimation, similarity helpers, and the
|
||||||
`LLMError` exception hierarchy.
|
`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,
|
LLMJudge,
|
||||||
PairedGrader,
|
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.models import BudgetTracker, LLMResponse, RunConfig
|
||||||
from llm_connect.openai import OpenAIAdapter
|
from llm_connect.openai import OpenAIAdapter
|
||||||
from llm_connect.openrouter import OpenRouterAdapter
|
from llm_connect.openrouter import OpenRouterAdapter
|
||||||
|
|
@ -143,6 +153,14 @@ __all__ = [
|
||||||
"AdaptiveRoutingPolicy",
|
"AdaptiveRoutingPolicy",
|
||||||
"ShadowingAdapter",
|
"ShadowingAdapter",
|
||||||
"LLMServer",
|
"LLMServer",
|
||||||
|
"ImageRenderRequest",
|
||||||
|
"ImageRenderResult",
|
||||||
|
"ImageReceipt",
|
||||||
|
"ImageSpendPolicy",
|
||||||
|
"ImageSpendGate",
|
||||||
|
"OpenRouterImageClient",
|
||||||
|
"create_image_client",
|
||||||
|
"render_image",
|
||||||
"ModelRate",
|
"ModelRate",
|
||||||
"ModelRateRegistry",
|
"ModelRateRegistry",
|
||||||
"CostEstimate",
|
"CostEstimate",
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,22 @@ from llm_connect.balance import (
|
||||||
get_account_balance,
|
get_account_balance,
|
||||||
)
|
)
|
||||||
from llm_connect.costs import estimate_cost
|
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.factory import create_adapter
|
||||||
from llm_connect.fx import resolve_fx_rate
|
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.models import RunConfig
|
||||||
from llm_connect.problem_classes import ProblemClass, ProblemClassRegistry
|
from llm_connect.problem_classes import ProblemClass, ProblemClassRegistry
|
||||||
from llm_connect.quality import QualityLedger
|
from llm_connect.quality import QualityLedger
|
||||||
|
|
@ -135,9 +148,163 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||||
)
|
)
|
||||||
balance.set_defaults(func=_balance_show)
|
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
|
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:
|
def _rates_show(args: argparse.Namespace) -> int:
|
||||||
registry = ModelRateRegistry.default()
|
registry = ModelRateRegistry.default()
|
||||||
if args.rates:
|
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:
|
Exposes:
|
||||||
POST /execute — run a prompt through the configured adapter
|
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
|
GET /health — liveness probe
|
||||||
|
|
||||||
Usage (programmatic)::
|
Usage (programmatic)::
|
||||||
|
|
@ -21,6 +22,7 @@ Usage (CLI)::
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import base64
|
||||||
import datetime as _dt
|
import datetime as _dt
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
|
@ -43,13 +45,22 @@ from llm_connect.exceptions import (
|
||||||
LLMRateLimitError,
|
LLMRateLimitError,
|
||||||
LLMTimeoutError,
|
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.models import LLMResponse, RunConfig
|
||||||
from llm_connect.profiles import ProfiledLLMAdapter, default_runtime_profiles
|
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):
|
class _AdapterHTTPServer(ThreadingHTTPServer):
|
||||||
adapter: LLMAdapter
|
adapter: LLMAdapter
|
||||||
|
image_client: ImageClient | None
|
||||||
|
image_gate: ImageSpendGate | None
|
||||||
|
|
||||||
|
|
||||||
class _Handler(BaseHTTPRequestHandler):
|
class _Handler(BaseHTTPRequestHandler):
|
||||||
|
|
@ -73,6 +84,9 @@ class _Handler(BaseHTTPRequestHandler):
|
||||||
|
|
||||||
def do_POST(self) -> None:
|
def do_POST(self) -> None:
|
||||||
parsed = urlsplit(self.path)
|
parsed = urlsplit(self.path)
|
||||||
|
if parsed.path == "/images":
|
||||||
|
self._post_image()
|
||||||
|
return
|
||||||
if parsed.path != "/execute":
|
if parsed.path != "/execute":
|
||||||
self._respond(404, {"error": "not found"})
|
self._respond(404, {"error": "not found"})
|
||||||
return
|
return
|
||||||
|
|
@ -132,6 +146,52 @@ class _Handler(BaseHTTPRequestHandler):
|
||||||
status, body = _error_response(exc)
|
status, body = _error_response(exc)
|
||||||
self._respond(status, body)
|
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 ────────────────────────────────────────────────────
|
# ── helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _respond(self, status: int, body: dict) -> None:
|
def _respond(self, status: int, body: dict) -> None:
|
||||||
|
|
@ -157,9 +217,13 @@ class LLMServer:
|
||||||
adapter: LLMAdapter,
|
adapter: LLMAdapter,
|
||||||
host: str = "127.0.0.1",
|
host: str = "127.0.0.1",
|
||||||
port: int = 8080,
|
port: int = 8080,
|
||||||
|
image_client: ImageClient | None = None,
|
||||||
|
image_gate: ImageSpendGate | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._httpd = _AdapterHTTPServer((host, port), _Handler)
|
self._httpd = _AdapterHTTPServer((host, port), _Handler)
|
||||||
self._httpd.adapter = adapter
|
self._httpd.adapter = adapter
|
||||||
|
self._httpd.image_client = image_client
|
||||||
|
self._httpd.image_gate = image_gate
|
||||||
self._thread: threading.Thread | None = None
|
self._thread: threading.Thread | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -364,6 +428,25 @@ def main(argv: list[str] | None = None) -> None:
|
||||||
default=_truthy(os.environ.get("LLM_CONNECT_STRICT_PROFILES", "")),
|
default=_truthy(os.environ.get("LLM_CONNECT_STRICT_PROFILES", "")),
|
||||||
help="Reject non-profile model_name values instead of passing them through.",
|
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)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
adapter = _build_adapter(
|
adapter = _build_adapter(
|
||||||
|
|
@ -372,7 +455,28 @@ def main(argv: list[str] | None = None) -> None:
|
||||||
enable_profiles=not args.disable_profiles,
|
enable_profiles=not args.disable_profiles,
|
||||||
strict_profiles=args.strict_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}")
|
print(f"llm_connect server listening on http://{args.host}:{args.port}")
|
||||||
try:
|
try:
|
||||||
server.serve_forever()
|
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__
|
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():
|
def test_wp_0007_spend_primitives_are_exported_from_package_root():
|
||||||
expected_names = [
|
expected_names = [
|
||||||
"FxRate",
|
"FxRate",
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
Tests for LLMServer HTTP serve mode (FR-1).
|
Tests for LLMServer HTTP serve mode (FR-1).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
|
@ -18,6 +19,7 @@ from llm_connect._diagnostics import (
|
||||||
)
|
)
|
||||||
from llm_connect.adapter import ErrorLLMAdapter, MockLLMAdapter
|
from llm_connect.adapter import ErrorLLMAdapter, MockLLMAdapter
|
||||||
from llm_connect.exceptions import LLMAPIError, LLMConfigurationError, LLMTimeoutError
|
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.models import LLMResponse, RunConfig
|
||||||
from llm_connect.profiles import CUSTODIAN_TRIAGE_BALANCED, ProfiledLLMAdapter, RuntimeProfile
|
from llm_connect.profiles import CUSTODIAN_TRIAGE_BALANCED, ProfiledLLMAdapter, RuntimeProfile
|
||||||
from llm_connect.server import LLMServer
|
from llm_connect.server import LLMServer
|
||||||
|
|
@ -138,6 +140,14 @@ class TestExecute:
|
||||||
assert status == 400
|
assert status == 400
|
||||||
assert body == {"error": "invalid JSON body"}
|
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):
|
def test_unknown_post_path_returns_404(self, server):
|
||||||
status, body = _post(
|
status, body = _post(
|
||||||
f"http://127.0.0.1:{server.port}/wrong",
|
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 [status for status, _body in results] == [200, 200]
|
||||||
assert elapsed < 1.5
|
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