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.
pip install stapel-agent| Fact | Value |
|---|---|
| Version | 0.25.0 |
| 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 |
capabilities.json · llms.txt (for agents)
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.
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.
| 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 |
| 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.
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 pathA 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.
| 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.
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.
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.
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.
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.