Why this service is shaped the way it is. For what it does and how to run it, see the README; for how to change it, see CONTRIBUTING.md.
Every internal tool that wants an LLM — a docs helper bot, the meeting service's minutes generation, future dashboards — could call Bedrock directly. The reason none of them do:
- Credentials live in one place. AWS credentials and the model catalog are configured once here, not scattered across every bot and job that wants a completion.
- Consumers are revocable individually. Each holds its own scoped
llm_key. Cutting one off is dropping its entry fromCONSUMER_KEYSand redeploying — it doesn't touch anyone else. - The provider can change without touching consumers. Re-pointing at a different Bedrock endpoint, or swapping the provider entirely, is a config change here. No consumer redeploys.
- Consumers can be any language, anywhere. The contract is HTTP + JSON, not an SDK.
This is the platform's API-only principle applied to a service that owns a credential rather than a domain. Nothing runs inside llm except the chat proxy: no queues, no scheduled jobs, no UI, no persistence.
contracts/chat.py Pydantic wire types (ChatRequest, ChatResponse, Usage)
▲
src/api/ FastAPI — routing, auth, error→status mapping
│ depends on
▼
src/providers/base.py LLMRequest / LLMResult / LLMProvider Protocol
▲ + ProviderError hierarchy
│
src/providers/bedrock_converse.py ─┐ two implementations,
src/providers/bedrock.py ─┘ selected by config
The rules:
contracts/imports nothing fromsrc/.src/providers/imports no FastAPI. It speaks in dataclasses (LLMRequest/LLMResult), not Pydantic models — deliberately, so the wire format and the provider interface can drift apart without dragging each other along.src/api/deps.pyis the single wiring point. The@lru_cachesits on the private_key_store()/_provider()builders;get_key_storeandget_llmare thin uncached wrappers over them, and tests override those viaapp.dependency_overrides. The distinction matters when you need to reset state between tests — it'sdeps._key_store.cache_clear(), notget_key_store.cache_clear()(the wrapper has nocache_clear). Seetests/conftest.py.
The mapping between the two type families happens in exactly one place, src/api/routers/chat.py. That's the whole job of the router: translate ChatRequest → LLMRequest, catch ProviderError, translate LLMResult → ChatResponse.
LLMProvider is a one-method Protocol:
class LLMProvider(Protocol):
def chat(self, request: LLMRequest) -> LLMResult: ...Everything vendor-specific lives behind it. The router never imports boto3 or anthropic, never sees a Bedrock model id, and never knows which provider is configured. That is what makes the test suite run with no AWS credentials and no network: a _FakeProvider satisfying the same Protocol is injected via dependency_overrides, and the router cannot tell the difference.
Provider selection is a Callable registry in src/providers/registry.py — LLM_PROVIDER names a key, the builder constructs the implementation from Settings. Adding a backend is a new module plus one dict entry.
Both bill as standard Amazon Bedrock (AWS credits apply) — deliberately not Claude Platform on AWS / Marketplace. They differ only in which Bedrock endpoint they call and how model ids are formed:
bedrock-converse (default) |
bedrock |
|
|---|---|---|
| Endpoint | bedrock-runtime Converse API |
Mantle Messages via AnthropicBedrockMantle |
| Model ids | US-regional cross-region inference profiles (us.anthropic.claude-sonnet-4-6) |
Global model ids |
| Requires | Regional profile access | Global/Messages model access |
bedrock-converse is the default because this account's model access is US-regional inference profiles, which the Messages endpoint cannot target. It maps neutral model names (claude-sonnet-4-6) to profile ids through an explicit table. If model access on the AWS account changes, that's the first thing to revisit.
src/providers/base.py defines three provider errors; the router maps each to one status:
ProviderError subclass |
Status | Cause |
|---|---|---|
ProviderRateLimited |
429 | Upstream returned 429 |
ProviderTimeout |
504 | Connection or timeout failure |
ProviderUnavailable |
502 | Upstream 5xx, auth failure, other status |
ProviderError itself is the catch-all → 502. A provider that raises a raw vendor exception is a bug: the router's except ProviderError won't catch it, and it becomes a 500.
Validation failures never reach the provider at all — Pydantic rejects an empty messages list or an unknown model with 422 before the handler body runs. Likewise, a valid key without the chat scope is rejected with 403 by the dependency, before any provider call. No budget is spent on a request that was going to fail. That ordering is deliberate.
There is no database, no Alembic, no docker compose for a DB, and no conversation storage. Consumers keep their own history and send the full turn list on each call.
The trade: a chattier wire format, in exchange for a service that has no schema to migrate, no state to lose on restart, and no retention policy to reason about for what is often sensitive text. For a service whose entire job is proxying one call, that's the right side of the trade.
The same reasoning drives the key model. Keys are seeded at boot from a CONSUMER_KEYS JSON array into an in-memory store satisfying platform_auth's ApiKeyStore protocol — the stateless equivalent of team-tracking's api_keys table. Rotating a key is a variable edit plus a redeploy. If that ever becomes too coarse, a DB-backed store drops in behind the same protocol with no route changes.
- Streaming.
/chatreturns the completed response. No SSE, no token streaming. Consumers that want progressive output would need a second endpoint and a different contract. - RAG / retrieval. No embedding, no document search. That belongs in the consumer.
- Prompt templates. The service does not own prompts.
meetingcomposes its own minutes prompt and sends it assystem+messages; llm just relays. This keeps prompt iteration in the service that cares about the output.