Jinja2-based templating for LLM-generated structured output
Project description
Genji is a templating library for LLM-generated structured output, built on Jinja2. It ensures templates own the structure and syntax (JSON brackets, HTML tags, YAML indentation) while LLMs only generate content, guaranteeing valid output every time.
The problem: LLMs often produce malformed JSON, broken HTML, or invalid YAML when asked to generate structured output directly.
The solution: Separate concerns. Templates define structure, LLMs fill in content. Structure is guaranteed, content is generated.
Installation
From PyPI (Recommended)
# With uv (recommended)
uv pip install genji
# With pip
pip install genji
From Source
git clone https://github.com/calebevans/genji.git
cd genji
uv pip install -e .
For development:
uv pip install -e ".[dev]"
pre-commit install
Quick Start
from genji import Template, LLMBackend
# Configure the LLM backend
backend = LLMBackend(model="gpt-4o-mini")
# Define a template (default_filter="json" applies to all gen() calls)
template = Template("""
{
"greeting": {{ gen("a friendly greeting for {name}") }},
"farewell": {{ gen("a warm farewell for {name}") }}
}
""", backend=backend, default_filter="json")
# Render with variables
result = template.render(name="Alice")
print(result) # Valid JSON guaranteed
# Or parse directly to dict
data = template.render_json(name="Alice")
print(data["greeting"]) # LLM-generated greeting
Note: On first run, LiteLLM may download model configurations. Subsequent runs use cached data.
Features
Template Syntax
Genji extends Jinja2 with a gen() function for LLM generation:
# Basic generation
{{ gen("a creative tagline") }}
# With variable interpolation
{{ gen("a description of {product}") }}
# With generation parameters
{{ gen("a tweet", max_tokens=280, temperature=0.9) }}
# With filters for different formats
{{ gen("content") | json }} # JSON-safe string with quotes
{{ gen("content") | html }} # HTML entity escaping
{{ gen("content") | yaml }} # YAML-safe string
{{ gen("content") | xml }} # XML entity escaping
All standard Jinja2 features are supported:
{# Comments #}
{% if condition %}
{{ gen("something") }}
{% endif %}
{% for item in items %}
{{ gen("content for {item}") }}
{% endfor %}
Format-Specific Filters
Genji provides filters for safe escaping in different formats:
| Filter | Purpose | Example Output |
|---|---|---|
json |
JSON string with quotes | "Hello \"World\"" |
html |
HTML entity escaping | <b>text</b> |
xml |
XML entity escaping | <tag>content</tag> |
yaml |
YAML-safe string | "key: value" |
raw |
No escaping (use carefully!) | <dangerous> |
strip |
Remove whitespace | "text" |
lower |
Lowercase | "hello" |
upper |
Uppercase | "HELLO" |
truncate(n) |
Truncate to n chars | "Long te..." |
Important: The json filter outputs a complete JSON string value including quotes:
{{ gen("text") | json }} # Outputs: "the generated text"
Default Filters
Avoid repetition by setting a default filter:
# Apply | json to all gen() calls automatically
template = Template(source, backend, default_filter="json")
# Or use file extension auto-detection
template = Template.from_file("report.json.genji", backend)
# Auto-detects "json" filter from .json.genji extension
# Override for specific prompts when needed
{{ gen("normal content") }} # Gets json filter
{{ gen("special") | raw }} # Skips filter
{{ gen("html content") | html }} # Uses html instead
LLM Backend Support
Genji uses LiteLLM for unified access to 100+ LLM providers.
See the full list of supported models.
OpenAI
backend = LLMBackend(
model="gpt-4o-mini",
api_key="sk-...", # pragma: allowlist secret
)
Anthropic Claude
backend = LLMBackend(
model="claude-3-5-sonnet-20241022",
api_key="sk-ant-...", # pragma: allowlist secret
)
Google Gemini
backend = LLMBackend(
model="gemini/gemini-2.5-flash",
api_key="...", # pragma: allowlist secret
)
Local Ollama
backend = LLMBackend(
model="ollama/llama3",
base_url="http://localhost:11434"
)
Azure OpenAI
backend = LLMBackend(
model="azure/your-deployment-name",
api_key="...", # pragma: allowlist secret
base_url="https://your-resource.openai.azure.com"
)
For a complete list of supported models, see LiteLLM's model documentation.
Loading Templates from Files
# Create a template file: templates/report.json.genji
template = Template.from_file("templates/report.json.genji", backend)
result = template.render(topic="climate change")
Batch Generation
Genji automatically batches multiple gen() calls for efficiency:
template = Template("""
{
"field1": {{ gen("prompt1") | json }},
"field2": {{ gen("prompt2") | json }},
"field3": {{ gen("prompt3") | json }}
}
""", backend=backend)
# All 3 prompts are sent to the LLM in parallel!
result = template.render()
Async Support
Every synchronous method has an async counterpart prefixed with a.
This works with any async framework built on asyncio (FastAPI, aiohttp,
etc.):
import asyncio
from genji import Template, LLMBackend
backend = LLMBackend(model="gpt-4o-mini")
template = Template("""
{
"greeting": {{ gen("a friendly greeting for {name}") }},
"farewell": {{ gen("a warm farewell for {name}") }}
}
""", backend=backend, default_filter="json")
async def main():
# Async rendering
result = await template.arender(name="Alice")
# Async render + JSON parse
data = await template.arender_json(name="Alice")
# Async file loading
t = await Template.afrom_file(
"report.json.genji", backend
)
asyncio.run(main())
When the backend supports native async (as LLMBackend does via
litellm.acompletion), all LLM calls use true async I/O.
If a backend only implements the sync protocol, arender automatically
falls back to running the sync calls in a thread via
asyncio.to_thread.
The synchronous API (render, render_json, from_file) is
unchanged and continues to work exactly as before.
API Reference
Template
class Template:
def __init__(
self,
source: str,
backend: LLMBackend | MockBackend,
default_filter: str | None = None
) -> None:
"""Initialize a template from a string.
Args:
source: Template string with Jinja2 syntax and gen() calls.
backend: LLM backend instance (LLMBackend or MockBackend).
default_filter: Optional default filter to apply to all gen() calls
(e.g., "json", "html", "yaml"). Can be overridden per-prompt.
"""
@classmethod
def from_file(
cls,
path: str | Path,
backend: LLMBackend | MockBackend,
default_filter: str | None = None
) -> Template:
"""Load a template from a file.
Args:
path: Path to template file.
backend: LLM backend instance.
default_filter: Optional default filter. If None, auto-detects from
file extension (.json.genji -> "json", .html.genji -> "html", etc.).
"""
def render(self, **context: Any) -> str:
"""Render the template with the given context variables.
Returns:
Rendered template as a string.
"""
def render_json(self, **context: Any) -> dict[str, Any]:
"""Render the template and parse as JSON.
Returns:
Parsed JSON as a Python dict.
Raises:
TemplateRenderError: If output is not valid JSON.
"""
async def arender(self, **context: Any) -> str:
"""Async version of render()."""
async def arender_json(self, **context: Any) -> dict[str, Any]:
"""Async version of render_json()."""
@classmethod
async def afrom_file(
cls,
path: str | Path,
backend: LLMBackend | MockBackend,
default_filter: str | None = None
) -> Template:
"""Async version of from_file()."""
LLMBackend
class LLMBackend:
def __init__(
self,
model: str | None = None,
api_key: str | None = None,
base_url: str | None = None,
temperature: float | None = None,
max_tokens: int | None = None,
add_system_prompt: bool = True,
**kwargs: Any,
) -> None:
"""Initialize the LiteLLM backend.
Args:
model: Model name (required, or set GENJI_MODEL env var).
api_key: API key (or set via environment variable).
base_url: Base URL for custom endpoints.
temperature: Temperature for generation (None uses provider default).
max_tokens: Max tokens per generation (None uses provider default).
add_system_prompt: Whether to add instruction for concise responses.
Defaults to True.
**kwargs: Additional arguments passed to litellm.completion().
"""
Per-Prompt Parameters
You can configure generation parameters for individual gen() calls:
# Control tokens, temperature, and stop sequences per prompt
{{ gen("short title", max_tokens=20) }}
{{ gen("creative content", temperature=0.9) }}
{{ gen("haiku", stop=["\n\n"]) }}
Smart Prompting
By default, Genji adds a system instruction to ensure LLMs return literal, concise responses:
# Default - LLM returns exactly what's requested
backend = LLMBackend(model="gpt-4o-mini")
# "a title" returns one title, not a list of options
# Disable for full control
backend = LLMBackend(model="gpt-4o-mini", add_system_prompt=False)
MockBackend
For testing without API calls:
from genji import MockBackend
backend = MockBackend(default_response="Test content")
# or
backend = MockBackend(response_fn=lambda prompt: f"Response to: {prompt}")
Configuration
| Parameter | Default | Environment Variable | Description |
|---|---|---|---|
model |
Required | GENJI_MODEL |
LLM model name (must be specified) |
api_key |
None | GENJI_API_KEY |
API key for provider |
base_url |
None | GENJI_BASE_URL |
Custom endpoint URL |
temperature |
Provider default | N/A | Temperature for generation |
max_tokens |
Provider default | N/A | Max tokens per generation |
add_system_prompt |
True |
N/A | Add conciseness instruction |
Error Handling
Genji provides clear exception types:
GenjiError- Base exceptionTemplateParseError- Invalid template syntaxTemplateRenderError- Error during renderingBackendError- LLM backend failureFilterError- Filter application failure
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file genji-1.0.1.tar.gz.
File metadata
- Download URL: genji-1.0.1.tar.gz
- Upload date:
- Size: 25.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c043c54f6a0484ccaf258637a780f46457f667db6eedb2955332f07125f4f0a2
|
|
| MD5 |
58f2fab0cc982465edb9bee047ebe152
|
|
| BLAKE2b-256 |
c58b5be4c692efe5d17f2355f43b89b8e40bb934e64d37e9e47bdb8f8fe5aa3b
|
Provenance
The following attestation bundles were made for genji-1.0.1.tar.gz:
Publisher:
release.yml on calebevans/genji
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
genji-1.0.1.tar.gz -
Subject digest:
c043c54f6a0484ccaf258637a780f46457f667db6eedb2955332f07125f4f0a2 - Sigstore transparency entry: 1181441729
- Sigstore integration time:
-
Permalink:
calebevans/genji@b8947b65c3431caf41878fc6893dac2968f03858 -
Branch / Tag:
refs/tags/v1.0.1 - Owner: https://github.com/calebevans
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@b8947b65c3431caf41878fc6893dac2968f03858 -
Trigger Event:
release
-
Statement type:
File details
Details for the file genji-1.0.1-py3-none-any.whl.
File metadata
- Download URL: genji-1.0.1-py3-none-any.whl
- Upload date:
- Size: 22.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0fe309ca97f72584a911686e84cd6b1db6b3b37ec20af0f80314018fd421efe9
|
|
| MD5 |
c0bff7f24ed9b0d9d9c869b5cfd8f344
|
|
| BLAKE2b-256 |
d2f52686e1fd413addc0ff89f2fefb5b67e7d49981d43fc0a7ffbf76ca42f3aa
|
Provenance
The following attestation bundles were made for genji-1.0.1-py3-none-any.whl:
Publisher:
release.yml on calebevans/genji
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
genji-1.0.1-py3-none-any.whl -
Subject digest:
0fe309ca97f72584a911686e84cd6b1db6b3b37ec20af0f80314018fd421efe9 - Sigstore transparency entry: 1181441735
- Sigstore integration time:
-
Permalink:
calebevans/genji@b8947b65c3431caf41878fc6893dac2968f03858 -
Branch / Tag:
refs/tags/v1.0.1 - Owner: https://github.com/calebevans
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@b8947b65c3431caf41878fc6893dac2968f03858 -
Trigger Event:
release
-
Statement type: