LLM facade Django app for the Stapel framework


Keywords
django, stapel, llm, anthropic, openai, translation
License
MIT
Install
pip install stapel-agent==0.25.0

Documentation

stapel-agent

CI coverage pypi downloads python license llms.txt

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.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

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_id that paid for it; a call with no scope skips the cache entirely unless its source is listed in CACHE_ALLOW_UNSCOPED.
  • Subject requests reach it. AgentGDPRProvider (section agent) is registered into stapel_core.gdpr.gdpr_registry at 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 than PROMPT_LOG_RETENTION_DAYS and 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.