Complete reference for the lith package, version 0.1.0 — every public name,
every module, and the internals its three console scripts are built from.
For flags and file formats, see README → CLI reference and README → Recipe format. For why the package is shaped this way, see About the pipeline.
Conventions used below: Raises lists exceptions the function raises
directly or lets propagate unchanged. Paths accept str or pathlib.Path
wherever a signature says path.
- Package layout
lith— public APIlith.renderlith.layoutlith.aspectlith.calllith.recipelith.styleslith.pathslith.expandlith.imagebyteslith.cli.platelith.cli.presslith.cli.print- Exception summary
- Side effects and determinism
src/lith/
├── __init__.py public API — re-exports eight names
├── render.py prompt-template substitution, spec and layout blocks
├── aspect.py capability records, aspect resolution, pixel sizes
├── layout.py zone notes and panel arrangements
├── recipe.py Recipe dataclass, family keys, recipe loading
├── styles.py styles.json access
├── paths.py slug and output-path derivation
├── expand.py LLM-backed topic expansion
├── imagebytes.py image download, structural validation, dimensions
├── call/
│ ├── __init__.py uniform request/results and provider dispatcher
│ ├── capability.py model-to-provider routing
│ ├── creds.py four-tier credential resolution
│ ├── http.py urllib JSON transport and typed provider errors
│ ├── xai.py xAI generations adapter
│ ├── openai.py OpenAI generations adapter
│ └── minimax.py MiniMax text-to-image adapter
├── data/styles.json the seven style families
└── cli/
├── plate.py lith-plate entry point
├── press.py lith-press entry point
└── print.py lith-print entry point
The prompt side remains pure and points downward only:
recipe ← styles
↑ ↑
└──────────┴── render ← __init__ ← cli.generate
↗ ↖
aspect layout (pure; depend on nothing in-package)
paths, expand (leaves; depend on nothing in-package)
aspect, imagebytes ← call adapters ← lith.call dispatcher ← cli.call
imagebytes, render, paths ← cli.run
render is the only module that composes others: it resolves the frame through
aspect, describes the zones through layout, and serializes the copy block
itself. The provider layer may import pure modules such as aspect and
imagebytes; pure modules never import lith.call or lith.cli.
Runtime dependencies: none beyond the standard library, and no external
binaries. expand_brief starts only the llm_cmd a caller supplies. Network
access is isolated to imagebytes.download and the provider adapters' shared
urllib JSON transport.
__init__.py re-exports eight names. __all__ lists exactly these.
| Name | Kind | Defined in |
|---|---|---|
render_prompt |
function | lith.render |
load_recipe |
function | lith.recipe |
recipe_from_brief |
function | lith.recipe |
validate_brief |
function | lith.recipe |
expand_brief |
function | lith.expand |
parse_brief_response |
function | lith.expand |
output_path |
function | lith.paths |
slug |
function | lith.paths |
from lith import load_recipe, render_promptNames not in __all__ — Recipe, FAMILY_KEYS, load_styles, get_family,
format_spec, format_layout, and the module constants — are importable from
their defining modules and are used by the console scripts.
render_prompt(
style: dict[str, Any] | Recipe,
brief: dict[str, Any] | None = None,
model: str | None = None,
) -> dict[str, str]Substitutes brief values into a family's prompt_template.
Two calling forms:
| Form | Behavior |
|---|---|
render_prompt(recipe) |
Resolves the family from the bundled styles.json using recipe.style, and uses recipe.brief. |
render_prompt(style_mapping, brief_mapping) |
Uses the supplied mapping as the family definition. No file is read. |
Returns a dict with exactly six keys:
| Key | Source |
|---|---|
prompt |
style["prompt_template"] with slots substituted |
negative_prompt |
style.get("negative_prompt", ""), coerced to str |
aspect_ratio |
resolve_aspect |
style |
style["name"], coerced to str |
aspect_note |
str when the model forced a substitution, else None |
copy_note |
copy_note — str when the copy block is too thin, else None |
model selects the capability set the ratio is clamped against. Rendering a
Recipe takes it from recipe.model unless overridden. With no model, no
clamping happens.
Slot substitution. The template is formatted with exactly seven keyword arguments, regardless of which the template uses:
| Slot | Value | Fallback |
|---|---|---|
{headline} |
brief["headline"] |
"NEW" |
{icon} |
brief["icon"] |
"gear" |
{volume} |
brief["volume"] |
"1" |
{base_color} |
brief["base_color"], else style["palette"]["background"] |
"#000000" |
{accent} |
brief["accent"], else style["palette"]["accent"] |
"#00E5FF" |
{spec} |
format_spec(brief) |
"" for an empty brief |
{layout} |
format_layout(brief, landscape) |
the title zone alone |
Both palette slots take the brief's value first. D_manga is the family this
matters for: its palette.background lists three colors.
Because every slot has a fallback, an empty brief renders successfully against any bundled family:
>>> render_prompt(get_family(load_styles(), "B"), {})["aspect_ratio"]
'16:9'validate_brief(brief) validates generated or hand-authored brief mappings;
recipe_from_brief(brief, *, style, model, n, name, description) applies that
validation and constructs a Recipe. These are the intended bridge from
expand_brief to rendering.
Raises
| Exception | Condition |
|---|---|
KeyError |
The template references a slot outside the seven above. |
KeyError |
style has no prompt_template or no name. |
TypeError |
brief is supplied alongside a Recipe ("brief must be omitted when rendering a Recipe"). |
TypeError |
style is a mapping and brief is None ("brief is required when style is a mapping"). |
ValueError |
format_spec hits a section with no heading. |
A custom family may use any subset of the seven slots; an eighth slot raises:
>>> render_prompt({"name": "x", "prompt_template": "{nope}"}, {})
KeyError: 'nope'format_spec(brief: dict[str, Any]) -> strSerializes the brief's copy fields into the literal block substituted at
{spec}. Every line it emits is text the model is instructed to reproduce
character for character.
Emitted in this fixed order, each part omitted when its field is absent. The
block is purely literal text — diagram is a description, so it lives in
format_layout among the instructions instead:
| Part | Source | Form |
|---|---|---|
| Title | brief["title"], else brief["headline"] |
TITLE: <text> |
| Subtitle | brief["subtitle"] |
SUBTITLE: <text> |
| Sections | brief["sections"], in order |
SECTION <n> HEADING: <heading> then one - <line> per entry in lines |
| Footer | brief["footer"] |
FOOTER: <text> |
Parts are joined with \n. Section numbering is 1-based and follows list
order. A section with an empty or absent lines yields its heading alone.
Returns "" for a brief with none of these fields. A brief carrying only
headline degrades to a single TITLE: line, which is what every pre-spec
recipe produces.
Raises ValueError when a section has no heading, naming its 1-based
index and including its repr.
copy_note(spec: str, prompt: str) -> str | NoneWarns when the copy block is too thin relative to the instructions around it.
Returns None when len(spec) * 20 >= len(prompt) - len(spec), otherwise a
sentence naming both character counts.
The ratio matters rather than the raw length, because the template is a fixed
cost — palette, mood, layout preamble — that scales per family while only the
spec block varies. A brief with no sections renders a copy block of roughly
sixteen characters against fifteen hundred of instructions, and at that ratio
the model starts lettering the instructions: real output has printed palette
hex codes as panel headings and a font name as body copy.
This never blocks rendering. A title-only poster is a legitimate request, and
format_layout already emits only the zones the brief has
copy for. The note exists so the caller learns before the call rather than
after, and it reaches them three ways: on stderr from lith-plate, as a
[warn] line from lith-print, and as the envelope's copy_note field.
_palette_value(field: Any, default: str) -> strPrivate. Resolves one palette field to a string for template insertion.
| Input | Result |
|---|---|
list, non-empty |
Elements joined with `" |
list, empty |
default |
None or "" |
default |
| anything else | the value unchanged |
The join preserves every element rather than taking the first; A_sticker
is the family that relies on it, offering four accents in one prompt.
ARRANGEMENTS: dict[str, str]Maps a layout key to the phrase describing how section panels sit in the
frame: stack, two-column, three-column, grid-2x2, grid-2x3,
grid-3x2, grid-3x3, hero, sidebar, timeline, radial, masonry,
zigzag, split, diagonal.
DIAGRAM_POSITIONS: dict[str, str]below (default), above, beside, center. A radial arrangement forces
center.
_auto_arrangement(count: int, landscape: bool) -> strPrivate. The derived arrangement for a panel count and orientation, used by
resolve_arrangement when brief["layout"] is absent.
Column counts cap at two in portrait.
resolve_arrangement(
brief: dict[str, Any], count: int, landscape: bool = False
) -> strbrief["layout"] when set, else derived from count and orientation. Raises
ValueError naming every valid key when brief["layout"] is unknown.
Column counts cap at two in portrait. Derived values:
count |
portrait | landscape |
|---|---|---|
| 1 | stack |
stack |
| 2 | two-column |
two-column |
| 3 | hero |
three-column |
| 4 | grid-2x2 |
grid-2x2 |
| 5 | hero |
hero |
| 6 | grid-2x3 |
grid-3x2 |
| 7–9 | two-column |
grid-3x3 |
| 10+ | two-column |
two-column |
format_layout(brief: dict[str, Any]) -> strDescribes the zones the brief actually has copy for, substituted at
{layout}. Zones are numbered (1), (2), … and joined with \n.
The wording is deliberately aesthetic-neutral — it names structure, counts and
sizes only. Each family's prompt_template says how those zones are drawn, so
one function serves all seven.
format_layout(brief: dict[str, Any], landscape: bool = False) -> str| Zone | Emitted when | Notes |
|---|---|---|
| Title | always | Sized 12-15% of frame height with sections present, 30-40% and "dominating the composition" without. Gains a subtitle clause when subtitle is set. |
| Section panels | sections is non-empty |
Arranged per resolve_arrangement. Names the panel count and the longest section's line count, and forbids padding a short panel. |
| Drawing | diagram is set |
Placed per diagram_position. Carries the description and the order to letter only the labels it names. |
| Footer | footer is set |
A rule with the footer text beneath. |
Every note is lowercase prose. Nothing here may read like a heading: the block sits in the same prompt as the verbatim-copy order, and ALL-CAPS zone labels were lettered into real output as visible headings before this was fixed.
Raises ValueError for an unknown layout or diagram_position.
Pure — no file or network access, and deterministic for a given brief. Why the zones track the spec instead of being a fixed skeleton: About the pipeline → Why the copy is specified.
MODEL_ASPECTS: dict[str, ModelCapability]Every known model carries one aspect-capability variant plus its request limits. A model absent from the table is unconstrained by the prompt renderer; the call dispatcher still rejects unknown ids.
| Model | Aspect variant | Other limits |
|---|---|---|
grok-imagine-image-2.0 |
14-value ratio_enum, including auto |
n_max=10 |
grok-imagine-image-quality, grok-imagine-image |
9-value ratio_enum |
n_max=10 |
gpt-image-2, gpt-image-2-2026-04-21 |
pixel_range: edges ÷16 and ≤3840; ratio 1:3–3:1; 655,360–8,294,400 pixels; allows_auto=True |
n_max=10 |
gpt-image-1.5, gpt-image-1, gpt-image-1-mini |
pixel_sizes=("1024x1024", "1536x1024", "1024x1536", "auto") |
n_max=10 |
image-01 |
8-value ratio_enum |
n_max=9, prompt_max_chars=1500 |
The xAI 2.0 enum is 1:1, 3:4, 4:3, 9:16, 16:9, 2:3, 3:2,
9:19.5, 19.5:9, 9:20, 20:9, 1:2, 2:1, auto. The MiniMax enum
is 1:1, 16:9, 4:3, 3:2, 2:3, 3:4, 9:16, 21:9. Adapters never
send auto; lith resolves a concrete frame.
@dataclass(frozen=True)
class PixelSizeRange:
edge_multiple: int
min_aspect: float
max_aspect: float
min_pixels: int
max_pixels: int
max_edge: int
allows_auto: bool = FalseThe interacting constraints for a model that accepts arbitrary pixel sizes.
It is data only; pixel_size performs the search.
@dataclass(frozen=True)
class ModelCapability:
n_max: int
prompt_max_chars: int | None = None
ratio_enum: frozenset[str] | None = None
pixel_sizes: tuple[str, ...] | None = None
pixel_range: PixelSizeRange | None = NoneExactly one of ratio_enum, pixel_sizes, and pixel_range must be non-null;
construction otherwise raises ValueError. n_max and a non-null
prompt_max_chars must be positive. aspect in capability checks a ratio enum,
reduces fixed pixel sizes to ratios, or tests the range's aspect bounds.
ratio(aspect: str) -> float | NoneWidth over height. None for anything that is not N:M with both terms
nonzero — "auto", "0:0", a non-string.
supported_by(model: str | None) -> ModelCapability | NoneMODEL_ASPECTS[model], or None for an unknown or absent model.
unsupported_aspect(model: str, aspect: str) -> str | NoneA message naming the supported set when model cannot produce aspect, else
None. A constrained pixel range admits every parseable ratio inside its
bounds rather than only a finite list of examples.
nearest_supported(model: str | None, aspect: str) -> strThe supported ratio closest to aspect by width/height, so a portrait request
lands on a portrait ratio. Returns aspect unchanged when the model is
unconstrained, already supports it, or when aspect is unparseable. A ratio
inside a constrained range is already supported and passes through unchanged.
pixel_size(model: str, aspect: str) -> strTranslates an OpenAI model and concrete ratio to "WIDTHxHEIGHT".
- The GPT Image 1.x models visibly clamp through
nearest_supported, then map to one of their three fixed sizes. gpt-image-2and its snapshot search sizes whose edges are divisible by 16, whose ratio and pixel area are in range, and whose maximum edge is 3840. The most accurate ratio wins; equal-error candidates prefer smaller area.
Pure and deterministic. Raises ValueError for an unknown model, a ratio-enum
model, an invalid or out-of-range aspect, or an aspect no legal pixel size can
represent.
>>> pixel_size("gpt-image-1", "16:9")
'1536x1024'
>>> pixel_size("gpt-image-2", "20:9")
'1280x576'request_limit_notes(model: str, n: int, prompt: str) -> list[str]Returns notes when n exceeds the model's n_max or len(prompt) exceeds its
prompt_max_chars. Unknown models return an empty list. This is advisory in
lith-plate; provider adapters still validate before network access.
content_aspect(brief: dict[str, Any], style: dict[str, Any]) -> str | NoneThe ratio the brief's content shape calls for, or None.
| Condition | Result |
|---|---|
style["prompt_template"] has no {spec} |
None — a custom family that opts out |
3 or more sections |
"2:3" |
1–2 sections |
"1:1" |
no sections |
None |
resolve_aspect(
brief: dict[str, Any],
style: dict[str, Any],
model: str | None = None,
) -> tuple[str, str | None]Resolves the final ratio and a note about any substitution. Precedence:
brief["aspect"]— set by you, or byexpand_brieffrom the topiccontent_aspect— spec-carrying families onlystyle["default_aspect"]FALLBACK_ASPECT("16:9")
Whatever the first four choose is then clamped by
nearest_supported. The note is None unless step 4
changed the answer.
All functions in this module are pure — no file, network, or subprocess access.
Provider-independent request and result types plus the model dispatcher. Importing
the package performs no credential lookup and no network access; the selected
adapter is imported lazily by generate.
@dataclass(frozen=True)
class ImageRequest:
prompt: str
model: str
aspect: str
n: int = 1
seed: int | None = None
resolution: str | None = None
quality: str | None = None
background: str | None = None
negative_prompt: str | None = Noneaspect is lith's resolved concrete W:H value. Adapters send only fields
their provider accepts. A supplied field that cannot be sent is recorded in
CallResult.unsupported; it is never appended or prepended to
prompt.
@dataclass
class Candidate:
index: int
data: bytes
mime: str
dimensions: tuple[int, int] | NoneProvider base64 is decoded before return, so every candidate holds bytes.
dimensions comes from PNG or JPEG headers and is None when the format's
dimensions cannot be read.
@dataclass
class CallResult:
candidates: list[Candidate]
model_reported: str | None
aspect_reported: str | None
revised_prompt: str | None
unsupported: dict[str, str]
cost: str | None
raw: dictmodel_reported, aspect_reported, and revised_prompt surface provider
evidence when it exists. A successful explicit xAI/OpenAI request falls back to
the requested model id when the response omits a top-level model. Aspect does
not receive that fallback: candidate dimensions are the evidence that a frame
was honored. cost passes through xAI's cost_in_usd_ticks as text; lith does
not estimate costs for any provider. raw is the provider response object.
generate(
request: ImageRequest,
*,
credential=None,
**provider_options,
) -> CallResultRoutes by exact model id and calls the corresponding adapter. credential may
be an already resolved Credential; when omitted, the adapter
resolves one itself. Unknown ids raise ValueError before any adapter import or
network access. Provider-specific keyword options are forwarded unchanged to
the selected adapter.
| Provider | Models | Translation |
|---|---|---|
| xAI | grok-imagine-image-2.0, grok-imagine-image-quality, grok-imagine-image |
Sends concrete aspect_ratio, optional resolution, n, and explicit response_format=b64_json. |
| OpenAI | the five gpt-image-* ids in MODEL_ASPECTS |
Sends pixel_size as size, plus optional quality/background. GPT Image returns b64_json without a response_format request field. |
| MiniMax | image-01 |
Sends an enum aspect_ratio or explicit width/height, optional seed, prompt_optimizer=false, and response_format=base64. |
OpenAI-only controls stay off the uniform dataclass and are keyword arguments
on lith.call.openai.build_request / generate:
generate(
request,
*,
credential=None,
output_format: str = "png",
output_compression: int = 100,
moderation: str = "auto",
) -> CallResultCompression is emitted only for JPEG or WebP. The top-level dispatcher exposes
these provider-specific controls through **provider_options, so callers may
use either lith.call.generate(request, output_format="webp", ...) or the
OpenAI adapter directly.
xAI's default response format is inline b64_json. Passing storage_options
through the dispatcher requests URL delivery instead:
generate(
request,
storage_options={
"filename": "candidate.jpg",
"expires_after": 3600,
"public_url": True,
},
)The adapter checks only that the provider-required filename is present; xAI
validates expires_after, public_url, and future provider options. The adapter
accepts the public URL under file_output.public_url or the candidate's
ordinary url, fetches it under lith's download guards, and still returns image
bytes in Candidate.data. The CLI does not expose this provider-specific option.
MiniMax enforces its 1500-character prompt cap before credential resolution or
network access and raises PromptTooLong, an InvalidRequest subclass, naming
the measured length, cap, and backlog §3.1. Every current integration prompt is
over that cap.
provider_for_model(model: str) -> strDefined in lith.call.capability. Maps a model id to the adapter name that
serves it — "xai", "openai", or "minimax" — from the MODEL_PROVIDERS
table. generate uses it to select which adapter module to import.
The table's keys are exactly the keys of
aspect.MODEL_ASPECTS: a model that can be routed has a
capability record, and a model with a capability record can be routed.
Raises ValueError for an unknown id, listing every supported model. There
is no default provider and no inference from the id's shape.
resolve_credential(
provider: str,
*,
recipe_path=None,
cwd=None,
environ=None,
home=None,
) -> CredentialSearches shell → recipe repository .env → ~/.hermes/.env →
~/.hermes/auth.json; the first usable tier wins. The environment names are
exactly XAI_API_KEY, OPENAI_API_KEY, and MINIMAX_API_KEY. Tier 4 accepts
only OAuth entries whose base_url matches the provider image API. Credential
holds provider, a repr-hidden secret, tier, source, auth_type, and
optional OpenAI organization/project ids; fingerprint is an eight-character
hash for inspection.
Raises MissingCredential after all four tiers, with every searched location
in the message, or CredentialFileError when an existing credential file
cannot be read or parsed. Credential resolution never writes a file.
lith.call.http defines this hierarchy:
ProviderError
├── AuthError
├── RateLimited
├── ContentRejected
└── InvalidRequest
The standard-library JSON transport retries HTTP 429 and 5xx once, redacts
authorization values from rendered errors, and maps HTTP/provider payloads to
those types. MiniMax's base_resp.status_code is checked even inside HTTP 200.
An OAuth-sourced xAI 401 becomes token expired — let Hermes refresh it; lith
never refreshes or writes Hermes credentials.
Three modules, one per provider, each exposing the same three names.
generate imports the one
provider_for_model selects.
| Module | Endpoint | Models |
|---|---|---|
lith.call.xai |
https://api.x.ai/v1/images/generations |
grok-imagine-image-2.0, grok-imagine-image-quality, grok-imagine-image |
lith.call.openai |
https://api.openai.com/v1/images/generations |
gpt-image-2, gpt-image-2-2026-04-21, gpt-image-1.5, gpt-image-1, gpt-image-1-mini |
lith.call.minimax |
https://api.minimax.io/v1/image_generation |
image-01 |
build_request(request: ImageRequest, **provider_options) -> dict[str, Any]
unsupported_fields(request: ImageRequest) -> dict[str, str]
generate(request, *, credential=None, **provider_options) -> CallResultbuild_request is pure: it performs no credential lookup and no network access,
which is what lith-press --dry-run prints.
Request bodies. Each adapter sends only what its provider documents.
| Field sent | xai | openai | minimax |
|---|---|---|---|
model, prompt, n |
✓ | ✓ | ✓ |
aspect_ratio |
✓ | — | ✓ when in the enum |
width / height |
— | — | ✓ otherwise |
size |
— | ✓ via pixel_size |
— |
response_format |
✓ b64_json |
— | ✓ base64 |
resolution |
✓ when supplied | — | — |
quality, background |
— | ✓ when supplied | — |
output_format, output_compression, moderation |
— | ✓ | — |
seed |
— | — | ✓ when supplied |
prompt_optimizer |
— | — | ✓ always False |
storage_options |
✓ when supplied | — | — |
n is validated against the provider's own ceiling: 1–10 on xAI and OpenAI,
1–9 on MiniMax.
Provider options are keyword-only and adapter-specific. xai takes
storage_options — a mapping of filename (required), expires_after, and
public_url — and switches response_format to url when it is present.
openai takes output_format, output_compression, and moderation.
minimax takes none.
Adapter-specific constants.
| Name | Module | Value |
|---|---|---|
GENERATION_TIMEOUT |
xai, openai |
180.0 seconds |
MODEL |
minimax |
"image-01" |
PROMPT_MAX_CHARS |
minimax |
1500 |
SUPPORTED_ASPECTS |
minimax |
the eight-ratio enum |
Raises. InvalidRequest for an out-of-range n, an unsupported
resolution, an aspect of "auto", a malformed storage_options, or a ratio
MiniMax cannot represent within 1% using width/height. minimax additionally
raises PromptTooLong — a subclass of InvalidRequest — before any credential
lookup or network call when the prompt exceeds PROMPT_MAX_CHARS, reporting the
measured length and the cap.
prompt_optimizer is sent explicitly as False rather than relying on the
MiniMax default: it rewrites the submitted prompt, which contains the literal
copy block.
FAMILY_KEYS: dict[str, str]Maps a style letter to a family key in styles.json.
| Letter | Key | Letter | Key |
|---|---|---|---|
A |
A_sticker |
E |
E_screenshot |
B |
B_brutalist |
F |
F_woodcut |
C |
C_patent |
G |
G_log |
D |
D_manga |
REQUIRED_BRIEF_KEYS: set[str] = {"topic", "headline", "icon"}Enforced by load_recipe. Note that volume is optional and topic is
validated but never substituted into any template.
@dataclass
class Recipe:
name: str
style: str
brief: dict
model: str
n: int
description: str | NonePlain dataclass — not frozen, no validation in __init__. Construct it
directly to bypass file loading and its checks.
family_key (property) → str. Returns FAMILY_KEYS[self.style]. Raises
KeyError if style is not one of A–G.
validate_brief(brief: Any) -> dict[str, Any]Validates brief data at the pipeline boundary and returns the same object
unchanged. Applied to every brief by
recipe_from_brief, and therefore by
load_recipe.
Checks, in order:
| Subject | Rule |
|---|---|
brief |
Must be a JSON object. |
topic, headline, icon |
Required. |
topic, headline, title, subtitle, diagram, footer, icon, volume |
When present, a non-empty string. |
base_color, accent |
A non-empty string, or a non-empty list of non-empty strings. |
sections |
A list; each entry an object with a non-empty heading, and lines a list of non-empty strings. |
aspect |
"auto" or a positive W:H ratio. |
layout |
One of ARRANGEMENTS. |
diagram_position |
One of DIAGRAM_POSITIONS. |
Raises ValueError naming the offending field, with 1-based indices for
sections and lines (brief section 2.lines[3] must be a non-empty string).
Returns the input object itself, not a copy. Validation does not normalize, default, or coerce any value.
recipe_from_brief(
brief: Any,
*,
style: str,
model: str = "grok-imagine-image-2.0",
n: int = 4,
name: str = "generated",
description: str | None = None,
) -> RecipeBuilds a validated Recipe from a brief already in memory — a brief produced by
expand_brief, or one assembled in code — without writing a
file. load_recipe is this function plus JSON reading.
| Argument | Rule |
|---|---|
style |
A key of FAMILY_KEYS: A–G. |
model |
A key of MODEL_ASPECTS. |
n |
An integer from 1 through that model's n_max. bool is rejected. |
name |
A non-empty string. |
description |
None, or a non-empty string. |
brief is passed through validate_brief.
Raises ValueError for an unknown style or model, an out-of-range n, an
empty name or description, or any brief violation.
>>> from lith import recipe_from_brief
>>> recipe = recipe_from_brief(brief, style="B", n=2)
>>> recipe.family_key
'B_brutalist'load_recipe(path: pathlib.Path | str) -> RecipeReads a JSON recipe file and returns a Recipe. Delegates every check to
recipe_from_brief, re-raising its ValueError prefixed
with the recipe path.
Defaults applied when a key is absent:
| Field | Default |
|---|---|
name |
path.stem |
model |
"grok-imagine-image-2.0" |
n |
4 |
description |
None |
brief |
{} — then fails validation |
description is carried on the dataclass and read by nothing — it is free
text for whoever opens the recipe file.
Raises
| Exception | Condition |
|---|---|
ValueError |
Recipe/brief shape is invalid, style/model is unknown, n exceeds its model limit, or an aspect/layout value is invalid. |
json.JSONDecodeError |
The file is not valid JSON. |
FileNotFoundError |
path does not exist. |
>>> r = load_recipe("recipes/live_test_recipe.json")
>>> r.family_key, r.model, r.n
('B_brutalist', 'grok-imagine-image-2.0', 1)load_styles(path: pathlib.Path | str | None = None) -> dictParses a styles file and returns it whole. With path as None, reads the
bundled lith/data/styles.json through importlib.resources, which works from
a zipped or relocated install. With a path, reads that file. Both are decoded
as UTF-8.
Returns the parsed document: {"version", "description", "families", "rules"}.
No schema validation is performed.
Raises FileNotFoundError for a missing explicit path;
json.JSONDecodeError for malformed JSON.
get_family(styles: dict, letter: str) -> dictReturns styles["families"][FAMILY_KEYS[letter]].
Raises KeyError — the letter if it is not A–G, or the family key if
the document lacks that family.
slug(text: str) -> strLowercases, replaces each run of non-alphanumeric characters with a single underscore, and strips leading and trailing underscores.
| Input | Output |
|---|---|
"32 LANGS" |
"32_langs" |
" A B--c " |
"a_b_c" |
"" |
"untitled" |
"!!!" |
"untitled" |
Non-ASCII alphanumerics are treated as separators: the pattern is
[^a-z0-9]+, applied after lowercasing. A headline of only non-ASCII
characters therefore yields "untitled".
output_path(
out_dir: pathlib.Path | str,
family_key: str,
headline: str,
ext: str,
) -> pathlib.PathReturns out_dir / f"{family_key}_{slug(headline)}{ext}". Pure — creates no
directory and touches no file. ext is concatenated verbatim, so it must
include its leading dot. cli.run passes "" to build a bare stem and then
appends the extension it sniffs from the image bytes.
>>> output_path("/o", "B_brutalist", "32 LANGS", ".png")
PosixPath('/o/B_brutalist_32_langs.png')default_output_dir(recipe_path: pathlib.Path | str) -> pathlib.PathReturns the directory artifacts for recipe_path publish to. A recipe whose
parent directory is named recipes resolves to that directory's sibling
outputs; any other recipe resolves to outputs beside itself.
| Recipe | Result |
|---|---|
/repo/recipes/x.json |
/repo/outputs |
/tmp/x.json |
/tmp/outputs |
Resolves symlinks via Path.resolve(). Pure — creates nothing.
Both console scripts default to this rather than Path.cwd() / "outputs",
because an agent's working directory is arbitrary and cwd-derived paths
scatter artifacts wherever the session started.
DEFAULT_PROMPT: strThe brief-expansion prompt template. Instructs the model to return a JSON
object carrying a full poster spec — topic, headline, subtitle,
sections (3–5 objects of {heading, lines}), diagram, footer, icon,
and aspect — to choose icon from
{gear, lightning, globe, skull, brain, rocket, lock}, to prefer 2:3 once
there are four or more sections, and to emit no prose outside the JSON block.
It also states the rule the whole design depends on: every word the model
writes is printed verbatim into the image, so it must write only text it wants
rendered, and keep total body copy to 60–140 words.
Contains a literal {topic} placeholder and a literal brace list, which is
why substitution uses str.replace rather than str.format.
expand_brief(
topic: str,
llm_cmd: list[str],
prompt_template: str = DEFAULT_PROMPT,
timeout: int = 60,
) -> dictRenders prompt_template by replacing every {topic} with topic, runs
llm_cmd with that text on stdin, and parses stdout with
parse_brief_response.
llm_cmd is a subprocess argv list supplied by the caller — lith names no
model and holds no credentials. The command must read the prompt from stdin and
write a reply containing a JSON object to stdout. Run with check=True,
text=True, and capture_output=True; stderr is captured and discarded unless
the command fails.
Returns the parsed object as-is. Fields are not validated against
REQUIRED_BRIEF_KEYS, so the result is not guaranteed to satisfy a recipe.
Raises
| Exception | Condition |
|---|---|
subprocess.CalledProcessError |
llm_cmd exits nonzero. |
subprocess.TimeoutExpired |
llm_cmd exceeds timeout seconds. |
FileNotFoundError |
llm_cmd[0] is not on $PATH. |
ValueError |
stdout contains no decodable JSON object. |
parse_brief_response(text: str) -> dictReturns the first decodable JSON object in text. Scans for each { and
attempts json.JSONDecoder().raw_decode at that index, returning the first
success. Trailing content after the object is ignored, so code fences,
preamble, and commentary are all tolerated:
>>> parse_brief_response('prose\n```json\n{"a": 1}\n```\ntail')
{'a': 1}
>>> parse_brief_response('{bad} then {"b": 2}')
{'b': 2}Because the scan is left-to-right and returns the first success, a valid object nested inside an earlier one wins only if the outer object fails to decode.
Raises ValueError if no { yields a decodable object. The message
includes the first 200 characters of text.
Entry point for lith-plate. Flags:
README → lith-plate.
build_brief(args: argparse.Namespace) -> dictAssembles a brief from flags: topic, headline, icon, and aspect from
args, plus a hard-coded "volume": "1". The --volume flag does not exist,
so family C's volume is always 1 from the CLI; a recipe file can set it.
main() -> intResolves the brief from --recipe or from flags, renders the prompt, and
prints either a summary or a press envelope. A non-null aspect_note or
copy_note is printed to stderr as warning: ... and carried in
the envelope. request_limit_notes are printed and
carried as limit_notes too. All can fire on the same render.
Precedence with --recipe: n and model come from the recipe, so --n and
--model are silently ignored. --seed and --out are read from flags in
both modes. When --out is absent, recipe mode anchors the extensionless stem
to default_output_dir(recipe); flag mode uses
cwd/outputs. lith-plate has no image bytes, so it does not name a format.
The envelope carries that stem; the summary prints it as
{stem}.<jpg|png|webp>, matching cli.run. An explicit --out is used
verbatim in both.
Without --recipe, --topic, --style, and --headline are each required;
a missing one triggers parser.error, which exits 2.
Returns 0. Argparse errors exit 2 without returning.
Entry point for lith-press. Flags:
README → lith-press.
render_notes(rendered: dict[str, Any]) -> dict[str, Any]Collects aspect_note, copy_note and limit_notes from a
render_prompt result, dropping any that are None, "" or
empty. _run calls it once, before branching, so --check and --dry-run
report substitutions too — a caller inspecting the plan is exactly the caller
who needs to know the plan was altered.
Every collected note is printed to stderr as warning: … (list-valued
limit_notes one line per entry) and merged into whichever JSON the command
emits: the routing decision under --check, the request preview under
--dry-run, the result metadata otherwise.
This exists because it did not. lith-plate surfaced all three and
lith-press surfaced none, so the command that spends money was the silent one:
a recipe clamped from 16:9 to 3:2 produced a correct image, a zero-byte
stderr, and a JSON payload with no mention of the substitution.
routing_decision(
recipe_model: str,
resolved_aspect: str,
*,
home=None,
environ=None,
) -> dict[str, str | None]Returns an inspectable Hermes-versus-lith-press decision. Hermes
image_generate is selected only when its active model exactly equals the
recipe model and the resolved aspect is 16:9, 1:1, or 9:16; every other
case routes to lith-press with the failed condition in reason. The Hermes
model comes from ~/.hermes/config.yaml image_gen.model, falling back to
FAL_IMAGE_MODEL.
request_preview(request: ImageRequest) -> dict[str, Any]Loads the selected adapter and returns its exact method, URL, redacted headers, request body, and unsupported fields. It performs no credential lookup and no network call. Provider preconditions still apply; in particular an over-limit MiniMax prompt fails here rather than printing a payload that cannot succeed.
main() -> int--auth reports all provider credential resolutions without requiring a
recipe. Other modes load and render the recipe into an ImageRequest:
| Mode | Effect |
|---|---|
--check |
Prints routing_decision; no credential or network access. |
--dry-run |
Prints request_preview; no credential or network access. |
| live | Resolves the selected provider credential, calls generate, and writes one candidate file per returned index. |
Candidate names are {family_key}_{slug(headline)}-c{index}{ext} under --out
or the recipe's default output directory. The extension is sniffed from the
bytes; an unrecognized format or duplicate candidate index raises before any
candidate is written. --emit-json returns candidate metadata and the complete
CallResult; human output prints paths, reported fields, cost, and unsupported
fields.
Returns 0. Argparse errors exit 2. Credential and provider exceptions
propagate.
Shared public helpers for recognizing, sizing, and downloading candidate image bytes. Provider adapters and both image-handling CLIs import these names.
| Constant | Value |
|---|---|
ALLOWED_SCHEMES |
("http", "https") |
DOWNLOAD_TIMEOUT |
30 (seconds) |
DOWNLOAD_MAX_BYTES |
26214400 (25 MiB) |
JPEG_MAGIC |
b"\xff\xd8\xff" |
PNG_MAGIC |
b"\x89PNG\r\n\x1a\n" |
image_size(body: bytes) -> tuple[int, int] | NoneReads (width, height) from a PNG IHDR, JPEG SOF marker, or VP8, VP8L, and VP8X
WebP header. Returns None for a container it cannot walk. This is structural
inspection, not a full pixel decode.
image_ext(body: bytes) -> str | NoneReturns ".jpg" when body starts with JPEG_MAGIC, ".png" for
PNG_MAGIC, ".webp" for b"RIFF" with b"WEBP" at offset 8, and None
otherwise. Header inspection only — no decode, no dimension check, no
validation of anything past byte 12.
looks_like_image(body: bytes) -> boolValidates the complete container structure: PNG chunk bounds and CRCs plus IHDR/IDAT/IEND, JPEG frame dimensions plus end marker, or RIFF sizing and a dimensioned VP8/VP8L payload. It does not perform OCR or semantic image review.
fetch_image(url: str) -> bytesApplies the same URL, redirect, size, and structural image guards as download, then
returns the validated bytes without writing a file. Provider adapters use this
for URL-form candidates.
download(url: str, dst: pathlib.Path) -> pathlib.PathFetches an image over HTTP(S) into dst, applying five guards in order:
- Scheme and host. A missing scheme or empty netloc raises
refusing to fetch non-URL. A scheme outsideALLOWED_SCHEMESraisesrefusing to fetch scheme. Comparison is case-insensitive. - Timeout.
DOWNLOAD_TIMEOUTon theurlopencall. - Post-redirect scheme. The final
response.urlscheme is re-checked, so anhttp(s)URL redirecting tofile:is refused. - Size ceiling. Chunks are counted while streaming and the read aborts
past
DOWNLOAD_MAX_BYTES, before the body is assembled or written. - Image structure.
looks_like_imagemust pass. HTML and truncated/corrupt containers fail here rather than landing on disk as a.jpg.
Sends User-Agent: lith/1.0. Creates dst.parent and writes only after all
five guards pass. Returns dst.
The response is accumulated in memory and written in a single write_bytes
once every guard has passed, so a failed fetch writes nothing at all.
DOWNLOAD_MAX_BYTES therefore bounds resident memory as well as disk.
Raises ValueError for any guard failure, plus urllib.error.URLError /
HTTPError / socket.timeout from the network layer.
>>> download("file:///etc/hosts", dst)
ValueError: refusing to fetch scheme 'file'; allowed: ('http', 'https')Not covered: DNS rebinding, redirect-count limits, and private-address
filtering — urllib's defaults apply, and a redirect to an internal HTTP host
is permitted.
Entry point for lith-print. Flags: README → lith-print.
aspect_mismatch(body: bytes, requested: str, tolerance: float = 0.02) -> str | NoneReturns a description when the delivered frame differs from requested by
more than tolerance (relative), else None. Returns None when the
dimensions cannot be read, when requested is not N:M ("auto"), or when
either term is zero — the check never raises and never blocks a publish.
>>> aspect_mismatch(jpeg_720x1280, "2:3")
'requested 2:3 (0.667), received 720x1280 (0.562)'A model may silently substitute a ratio it does not support, and the layout in
the prompt was composed for the frame that was requested, so cli.run prints
this as a [warn] line rather than letting the substitution pass unnoticed.
load_local(src: pathlib.Path, dst: pathlib.Path) -> pathlib.PathCopies a local image into the pipeline's staging path. Reads src whole, applies
the same looks_like_image check, creates dst.parent, and writes — unless
src and dst resolve to the same file, in which case the write is skipped.
Returns dst.
Raises FileNotFoundError if src is not a file; ValueError if structural
image validation fails.
main() -> intLoads the recipe, resolves the output directory from --output-dir or
default_output_dir, renders the prompt, and derives
the output stem via output_path(..., ""). Branches two ways:
| Branch | Effect |
|---|---|
| No image source | Prints recipe, family, style, aspect, model, n, prompt, and {stem}.<jpg|png|webp>. Writes nothing. |
| Image source | Stages the bytes at {stem}.part, warns if aspect_mismatch finds drift, then Path.replaces that onto {stem} plus the extension image_ext reads from the first 12 bytes. |
--strict promotes that drift warning to exit code 1. The publish still
happens first: the delivered bytes are what you need in order to see how the
frame was substituted, so withholding them would make the failure harder to
diagnose, not safer. The exit code — not the file's existence — is the signal a
caller should branch on.
Output stem: {output_dir}/{family_key}_{slug(headline)}. The extension is not
known until the bytes arrive — Grok returns JPEG, gpt-image-1 returns PNG —
so the artifact is named after what actually landed. Nothing is re-encoded, and
the published file is overwritten without prompting on a re-run.
The staging file exists because the extension cannot be chosen until the bytes
are in hand: they land at {stem}.part, are inspected, then renamed. It is not
a crash-safety measure — download and load_local both write their whole
body in one call after their guards pass, so a failed fetch leaves no file at
all. A .part survives only if the rename itself fails.
--image-url and --image-file are a mutually exclusive argparse group.
Progress lines print with flush=True.
Returns 0, or 1 when --strict is set and the frame drifted. Argparse
errors exit 2. Guard failures from download or load_local propagate as
tracebacks.
| Exception | Raised by | Trigger |
|---|---|---|
KeyError |
render_prompt |
Template slot outside the supported seven |
KeyError |
load_recipe |
Missing style key |
KeyError |
get_family, Recipe.family_key |
Style letter outside A–G |
TypeError |
render_prompt |
Recipe with a brief, or mapping without one |
ValueError |
format_spec, render_prompt |
A brief section with no heading |
ValueError |
load_recipe |
Missing required brief keys |
ValueError |
parse_brief_response, expand_brief |
No decodable JSON object |
ValueError |
pixel_size |
Unknown/non-pixel model, invalid ratio, or unreachable size |
ValueError |
provider_for_model, generate |
Unknown model or wrong-provider credential |
ValueError |
download |
Bad scheme, bad redirect, oversize body, non-image bytes |
ValueError |
load_local |
Structurally invalid or non-image bytes |
MissingCredential |
resolve_credential, provider generate |
All four credential tiers exhausted |
CredentialFileError |
resolve_credential |
Existing .env or auth.json unreadable/malformed |
InvalidRequest |
provider adapters | Invalid request field or combination before/during a call |
PromptTooLong |
MiniMax adapter | Prompt exceeds 1500 characters before network access |
AuthError |
provider transport | Invalid/unauthorized credential |
RateLimited |
provider transport | HTTP/provider rate limit |
ContentRejected |
provider transport | Content or safety rejection |
ProviderError |
provider transport/adapters | Other transport, response-shape, or provider error |
FileNotFoundError |
load_recipe, load_styles, load_local |
Missing input file |
json.JSONDecodeError |
load_recipe, load_styles |
Malformed JSON |
subprocess.CalledProcessError |
expand_brief |
llm_cmd exits nonzero |
subprocess.TimeoutExpired |
expand_brief |
Command exceeds timeout |
The call-specific exceptions above are custom types. AuthError,
RateLimited, ContentRejected, and InvalidRequest subclass ProviderError;
PromptTooLong subclasses InvalidRequest. All carry a human-readable message,
and provider errors may carry status_code and payload.
| Function | Filesystem | Network | Subprocess | Deterministic |
|---|---|---|---|---|
render_prompt |
— | — | — | yes |
format_spec, format_layout |
— | — | — | yes |
aspect helpers, pixel_size |
— | — | — | yes |
slug, output_path |
— | — | — | yes |
load_recipe |
read | — | — | yes |
load_styles, get_family |
read | — | — | yes |
parse_brief_response |
— | — | — | yes |
expand_brief |
— | via llm_cmd |
llm_cmd |
no |
resolve_credential |
read | — | — | yes for a fixed environment/filesystem |
provider build_request |
— | — | — | yes |
lith.call.generate |
read credentials | yes | — | no |
request_preview |
— | — | — | yes |
routing_decision |
config read | — | — | yes for fixed inputs/config |
download |
write | yes | — | no |
load_local |
read + write | — | — | yes |
render_prompt is a pure function of its arguments plus the bundled
styles.json, so the same recipe yields the same prompt across runs and
machines. Randomness enters through expand_brief's model and provider image
generation. Request building, aspect translation, and routing decisions remain
deterministic.
Output paths are derived, not unique: re-running a recipe overwrites the previous artifacts.
- README → CLI reference — the three console scripts
- README → Recipe format — the JSON schema
- About the pipeline — provider handoffs and deliberate omissions
- Tutorial: your first announcement image — the API in use