stapel-agent
LLM facade: JSON completion, translation, transcription and summarization in front of swappable model / speech-to-text / image-generation providers (Anthropic, OpenAI-compatible, Claude Code CLI, whisper-http, ElevenLabs, AssemblyAI, openai-images), with a prompt cache and a per-call token ledger. Python port of a prior NestJS service — same HTTP paths/contracts, plus an in-process comm surface for monolith deployments.
Part of the Stapel framework — composable Django apps that deploy as a monolith or as microservices without changing module code.
Install
pip install stapel-agent
At a glance
| Fact | Value |
|---|---|
| Version | 0.31.1 |
| Python | >=3.11 (3.11, 3.12, 3.13, 3.14) |
| HTTP operations | 5 |
| Config axes | 4 |
| Usage surface | 5 |
| Extension points | 4 |
| Fleet dependencies | stapel-core |
Documentation
capabilities.json · llms.txt (for agents)
What this is
Python port of a prior NestJS service. Same HTTP paths and contracts
(stapel-translate's AgentProvider keeps working unchanged), plus a comm
surface so monolith deployments call it in-process without HTTP.
Quick start
pip install stapel-agent[anthropic] # + the Anthropic SDK for the default provider
# settings.py
INSTALLED_APPS = [
...
'stapel_agent',
]
STAPEL_AGENT = {
"ANTHROPIC_API_KEY": "sk-ant-...",
}
# urls.py — mount the app under agent/
urlpatterns = [
...
path("agent/", include("stapel_agent.urls")),
]
Two surfaces, same contracts:
| Surface | HTTP | comm Function | Does |
|---|---|---|---|
| Complete | POST /agent/api/llm/complete |
llm.complete |
JSON completion: {"prompt", "model": "small|medium|large|xlarge", "system_prompt"?, "provider"?, "images"?} → parsed JSON in result, prose in comment. images (vision) entries are {url} or {data_b64, mime?} — OCR, screenshots, photo moderation |
| Translate | POST /agent/api/llm/translate |
llm.translate |
{"from"/"from_lang", "to", "entries": {key: text}} → {key: translated} (cached by prompt) |
| Transcribe | POST /agent/api/llm/transcribe |
llm.transcribe |
{"audio_url", "language"?, "diarization"?, "provider"?, "timeout_seconds"?} → a normalized transcript (words, utterances, speakers, timings) via the STT router |
| Summarize | POST /agent/api/llm/summarize |
llm.summarize |
exactly one of text / transcript (+ language?, model?, provider?) → Markdown summary + aggregated usage; long inputs are map-reduced |
| Generate image | POST /agent/api/llm/generate-image |
llm.generate_image |
{"prompt", "size"?, "n"? (1-10), "provider"?} → raw provider results [{url? | data_b64?, mime}] — storing them in a CDN/asset library is the caller's job |
# HTTP (service-to-service: X-API-KEY, or a staff session)
POST /agent/api/llm/complete {"prompt": "...", "model": "small|medium|large|xlarge",
"provider"?: "...", "system_prompt"?: "...",
"images"?: [{"url": "https://..."} | {"data_b64": "...", "mime"?: "image/webp"}]}
POST /agent/api/llm/translate {"from": "auto", "to": "de", "entries": {"key": "text"}}
POST /agent/api/llm/transcribe {"audio_url": "https://...presigned...", "language"?: "en",
"diarization"?: true, "provider"?: "elevenlabs"}
POST /agent/api/llm/summarize {"text": "..."} | {"transcript": {...llm.transcribe output...}}
POST /agent/api/llm/generate-image {"prompt": "a cat", "size"?: "1024x1024", "n"?: 2}
# comm (in-process in a monolith, transport chosen by STAPEL_COMM)
from stapel_core.comm import call
call("llm.complete", {"prompt": "...", "model": "small"})
call("llm.complete", {"prompt": "what is on this?", "model": "small",
"images": [{"url": "https://..."}]}) # url or data_b64 — never raw bytes
call("llm.translate", {"from_lang": "auto", "to": "de", "entries": {...}})
call("llm.transcribe", {"audio_url": "https://..."}) # URLs only — never raw bytes
call("llm.summarize", {"transcript": result["transcript"]})
call("llm.generate_image", {"prompt": "a cat", "n": 2})
LLM failures are HTTP 200 with
{"status": "failure", "reason": ...} — 4xx/5xx are reserved for request
validation and auth. Successful completions return the parsed JSON in
result, prose around it in comment, and snake_case usage
(input_tokens / output_tokens).
Every provider call writes a PromptLog row: model, size, source, status,
duration, the full token ledger (input / output / thinking / cache-read /
cache-write) and what it cost — cost_usd with a cost_basis saying whether
that is the provider's own figure, this package's rate card, or unknown.
Attribution is the optional user_id / workspace_id pair every llm.* comm
function accepts, so per-user and per-tenant cost accounting needs no other
table. The cost is stored as computed at call time and never recomputed:
re-pricing an old row against today's card would quietly restate history.
Transcriptions land there too (source=transcribe, model = STT provider
name, LLM token columns NULL but audio_duration_ms carrying the billable
audio length and cost_usd the rate card applied to it, the fallback walk in
metadata.attempts); each
summarize pass is a normal LLM row (source=summarize); image generations
log as source=generate_image with {count, mimes, bytes_total} in
metadata — image bytes never touch the ledger, and multimodal completions
never collide with the text-keyed prompt cache.
Transcription routes through an STT provider chain — explicit provider in
the request beats STT_LANGUAGE_ROUTES[lang], which beats
DEFAULT_STT_PROVIDER + STT_FALLBACK_CHAIN — and falls back only on
transient failures (429/5xx/timeouts); bad audio or auth errors never
retry on another provider.
Settings — STAPEL_AGENT
| Key | Default | Meaning |
|---|---|---|
MODELS |
{"small": "claude-haiku-4-5-20251001", "medium": "claude-sonnet-5", "large": "claude-opus-4-8", "xlarge": "claude-fable-5"} |
Size → model-name map |
PROVIDERS |
{} |
Overlay merged over the built-in registry (anthropic / openai-compat / claude-code) — add/override entries, None removes one; resolved lazily per request |
DEFAULT_PROVIDER |
"anthropic" |
Provider used when a request names none |
ANTHROPIC_API_KEY |
"" |
Key for the Anthropic SDK provider (read lazily) |
OPENAI_COMPAT_BASE_URL |
"" |
Base URL of any OpenAI-compatible endpoint |
OPENAI_COMPAT_API_KEY |
"" |
Bearer token for that endpoint |
OPENAI_COMPAT_MODELS |
{} |
Optional size → model map for openai-compat (missing sizes fall back to MODELS) |
CLI_BINARY |
"claude" |
Claude Code CLI binary (opt-in provider only) |
CLI_TIMEOUT |
120 |
Provider timeout, seconds |
MAX_TOKENS |
4096 |
Completion token cap |
STT_PROVIDERS |
{} |
Overlay merged over the built-in STT registry (whisper-http / elevenlabs / assemblyai) — same semantics as PROVIDERS |
DEFAULT_STT_PROVIDER |
"whisper-http" |
STT provider used when a request pins none and no language route matches |
STT_FALLBACK_CHAIN |
[] |
STT providers tried in order after the default — on transient failure only |
STT_LANGUAGE_ROUTES |
{} |
{"ru": ["gigaam", "whisper-http"], ...} language matrix (beats the default chain) |
STT_LANGUAGE_ALIASES |
{} |
Extra language codes mapped onto canonical ones ({"cmn": "zh"}). ISO 639-1 and its 639-2 aliases are built in; anything else is REFUSED unless listed here |
STT_QUOTA_WATCHDOG |
{"ENABLED": True, "WARN_RATIO": 0.10, "CRITICAL_RATIO": 0.02} |
Provider-balance sweep + the alert on a live quota refusal. One switch covers both |
STT_QA |
{"MAX_GAP_SECONDS": 5.0} |
Silence between consecutive transcript segments that flags qa.gap; 0 disables |
STT_TIMEOUT |
1800 |
Hard cap (seconds) on one STT provider's submit+poll cycle |
STT_DOWNLOAD_MAX_BYTES |
134217728 |
Byte cap on an audio-URL download (128 MiB), enforced mid-stream |
STT_DOWNLOAD_TIMEOUT |
30.0 |
Per-socket connect/read timeout for one hop of that download |
STT_DOWNLOAD_TOTAL_DEADLINE |
300.0 |
Ceiling on the whole download incl. redirects; a per-call timeout= may only lower it |
STT_DOWNLOAD_ALLOWED_HOSTS |
[] |
Exact-host allowlist for audio URLs, applied to every hop. Empty = the download is REFUSED (empty is not a wildcard) and W015 warns at boot. Settings-only (NO_ENV) — an env var cannot fill it |
STT_DOWNLOAD_ALLOW_ANY_HOST |
False |
Opt-out: accept audio from any public host when the allowlist is empty. The other fetch guards still apply |
WHISPER_BASE_URL / WHISPER_API_KEY / WHISPER_MODEL |
"" / "" / "whisper-1" |
OpenAI-compatible Whisper endpoint — the OpenAI API or self-hosted faster-whisper (key optional) |
ELEVENLABS_API_KEY / ELEVENLABS_STT_URL / ELEVENLABS_STT_MODEL |
"" / Scribe URL / "scribe_v2" |
ElevenLabs Scribe |
ELEVENLABS_SUBSCRIPTION_URL |
.../v1/user/subscription |
Character-allowance endpoint the quota watchdog polls |
ASSEMBLYAI_API_KEY / ASSEMBLYAI_BASE_URL / ASSEMBLYAI_MODEL |
"" / "https://api.assemblyai.com" / "universal" |
AssemblyAI (async submit+poll) |
IMAGE_PROVIDERS |
{} |
Overlay merged over the built-in image registry (openai-images) — same semantics as PROVIDERS |
DEFAULT_IMAGE_PROVIDER |
"openai-images" |
Image provider used when a request pins none |
IMAGES_BASE_URL / IMAGES_API_KEY |
"" / "" |
OpenAI-compatible /images/generations endpoint + key; both fall back to the OPENAI_COMPAT_* pair |
IMAGES_MODEL |
"" |
Optional model name (gpt-image-1, ...); empty = omitted from the request |
CACHE_LOOKUP |
{"llm_facade": False, "translate": True, "summarize": False} |
Per-source cache-by-prompt toggle (used by the default cache policy) |
CACHE_TTL |
604800 |
Cache window in seconds (7 days); older rows are ignored (default policy) |
CACHE_ALLOW_UNSCOPED |
[] |
Sources whose content the host declares non-personal; a call without user_id otherwise skips the cache |
PROMPT_LOG_RETENTION_DAYS |
90 |
Age after which purge_prompt_logs scrubs a ledger row's text, keeping its token counters |
PROMPT_LOG_RETENTION_SCHEDULED |
False |
Declares that the host's scheduler runs purge_prompt_logs; otherwise a system check (W014) says the window is unenforced |
MODEL_SIZE_CEILING_ENTITLEMENT |
None |
Entitlement key (STAPEL_BILLING plan catalog) capping how far up the size ladder a caller's plan reaches; unset = seam closed, no billing lookup at all — see resolve_size_ceiling |
CACHE_POLICY |
"stapel_agent.cache.PromptLogCachePolicy" |
Dotted path to a CachePolicy subclass — swap the prompt cache (Redis, no-op, ...) without forking |
Provider matrix
| Name | Class | Backend | Needs |
|---|---|---|---|
anthropic (default) |
providers.anthropic.AnthropicProvider |
Anthropic SDK | anthropic extra + ANTHROPIC_API_KEY |
openai-compat |
providers.openai_compat.OpenAICompatProvider |
Any /chat/completions dialect: OpenAI, DeepSeek, MiMo, GLM, Kimi |
OPENAI_COMPAT_BASE_URL (+ key) |
claude-code |
providers.claude_cli.ClaudeCodeCLIProvider |
Spawns claude -p ... --output-format json |
The CLI in the host image |
No CLI in any default path. claude-code is strictly opt-in: it exists for
hosts that ship the Claude Code CLI in their image and want the CLI to handle
its own authentication (provider: "claude-code" per request, or
DEFAULT_PROVIDER override). There is no OAuth credential
reading and no background token-refresh — that plumbing was deliberately
dropped.
Adding, overriding and removing providers (merge semantics)
STAPEL_AGENT["PROVIDERS"] is an overlay merged over the built-ins, not a
replacement dict — adding your provider never requires restating the three
shipped ones, and setting a name to None removes it:
# settings.py — one line per change, built-ins stay available
STAPEL_AGENT = {
"PROVIDERS": {
"acme": "myproject.llm.AcmeProvider", # add a custom backend
"claude-code": None, # remove a built-in
},
"DEFAULT_PROVIDER": "acme",
}
Or register at runtime from your app's AppConfig.ready() (highest
precedence):
from stapel_agent import register_provider
register_provider("acme", AcmeProvider) # class or dotted path
A custom backend is just a stapel_agent.LlmProvider subclass returning a
ProviderResult. Django system checks (stapel_agent.E001/W001/W002) flag a
DEFAULT_PROVIDER that is not in the effective registry, unimportable dotted
paths and non-LlmProvider entries at startup. See MODULE.md
for the full extension-point map.
STT provider matrix
| Name | Class | Backend | Needs |
|---|---|---|---|
whisper-http (default) |
stt.providers.whisper_http.WhisperHttpProvider |
OpenAI Whisper API or any self-hosted server speaking the same dialect (faster-whisper, whisper.cpp); accepts url/path/bytes refs | WHISPER_BASE_URL (+ key for OpenAI) |
elevenlabs |
stt.providers.elevenlabs.ElevenLabsProvider |
ElevenLabs Scribe (synchronous multipart), diarization | ELEVENLABS_API_KEY; URL refs only |
assemblyai |
stt.providers.assemblyai.AssemblyAIProvider |
AssemblyAI async submit+poll, diarization, 99-language universal model |
ASSEMBLYAI_API_KEY; URL refs only |
The STT registry has the same merge semantics as the LLM one
(STAPEL_AGENT["STT_PROVIDERS"] overlay, None removes, or
register_stt_provider() at runtime), and its own startup checks
(stapel_agent.W003/W004). A custom engine is a stapel_agent.SttProvider
subclass returning a NormalizedTranscript — MODULE.md walks through a
self-hosted GigaAM adapter as the worked example.
Vision & image generation
The two vision-capable LLM providers map images into their dialects
automatically — anthropic to image content blocks (URL or base64 source),
openai-compat to image_url parts (URL or data URI). claude-code has no
vision: an image request through it fails fast with status: "failure".
Image generation ships one built-in backend, openai-images — anything
speaking the OpenAI POST {base}/images/generations dialect (OpenAI,
Together, self-hosted). It is the third instance of the merge-registry
pattern (STAPEL_AGENT["IMAGE_PROVIDERS"] overlay, None removes,
register_image_provider() at runtime; startup checks
stapel_agent.W005/W006). Vendors with their own protocols (Stability,
Ideogram, ...) are an app-layer stapel_agent.ImageGenProvider subclass —
recipe in MODULE.md. The agent returns raw results and writes
the ledger; storage/placement belongs to the calling tier.
Staged analysis (stapel_agent.analysis)
A recognition chain that takes tens of seconds is a JOB, not a request.
analysis is that job: one JSON state document per subject, a stage apiece,
a fast stage whose answer is readable while a slow one still runs, and a slow
stage that writes progress and a growing partial result after every
batch rather than only at the end.
from stapel_agent.analysis import (
Fingerprint, ModelStateStore, ON_PHOTOS, Stage, StageBatch, runner,
)
stages = [
Stage("text", describe, depends_on=frozenset({ON_PHOTOS})),
Stage("features", fill), # yields StageBatch(...)
Stage("moderation", screen, blocking=False), # cannot fail the job
]
store = ModelStateStore()
state, started = runner.start(
store=store, key=key,
fingerprint=Fingerprint.of(photos=blobs, text=typed),
stages=stages, seller_filled=already_answered,
)
The fingerprint has two halves because they invalidate different work: a
stage declares which halves it depends on, so a refresh with edited text
re-runs the text-dependent stages and keeps the photo stage's answer.
start() is idempotent — the same photos and the same text are the same
question. AnalysisJobView is the POST/GET surface (?wait=1 waits for
the fast stage only), and analysis.blocks orders a catalogue's feature
definitions into the order a composer asks them while resolving them in
dependency order, giving every un-askable field an explicit reason instead
of dropping it.
Privacy & retention
PromptLog is both an accounting ledger and a copy of the conversation, so it
is treated as customer content:
- The cache is per-tenant. A cached answer belongs to the
user_idthat paid for it; a call with no scope skips the cache entirely unless its source is listed inCACHE_ALLOW_UNSCOPED. - Subject requests reach it.
AgentGDPRProvider(sectionagent) is registered intostapel_core.gdpr.gdpr_registryat app startup — export returns the user's rows, erasure scrubs their text and unlinks the subject while the token counters survive for accounting. - Text expires. Schedule
manage.py purge_prompt_logs(--days N,--dry-run): it scrubs prompt, system prompt, response and error text on rows older thanPROMPT_LOG_RETENTION_DAYSand keeps the counters.
License
MIT — see LICENSE.
This page is assembled by stapel-readme from docs/readme.md plus the contract artifacts in docs/. Edit the prose in docs/readme.md; the badges, facts and links above and below it are generated — do not hand-edit README.md.
Release files for stapel-agent 0.31.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| stapel_agent-0.31.1.tar.gz | 461.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| stapel_agent-0.31.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 815.9 kB
Release files / stapel_agent-0.31.1.tar.gz
| Download URL | stapel_agent-0.31.1.tar.gz |
|---|---|
| Size | 461.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
948ab91b1a958b40df00470a5d0148edda96b62d2afe7b1c2bd85f286d94bdea
|
|
BLAKE2b-256 checksum How to use checksums |
d5258946906781e1eb30cd1bb6801142d77a8b54c437dbafa6ec7079548c1f2e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 21, 2026.
Transparency logRelease files / stapel_agent-0.31.1-py3-none-any.whl
| Download URL | stapel_agent-0.31.1-py3-none-any.whl |
|---|---|
| Size | 354.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
d57d54336ebcb78c9f21b3289c8418366d8d604f3bee9e4d71d41a070c5b9f49
|
|
BLAKE2b-256 checksum How to use checksums |
e97a18c5095d1fbd18b525c5f1bc9fd05dfc63eecaba464b6a8bdaf4101e88ea
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 21, 2026.
Transparency log