
groq.chat.completions.create(...) directly from the request handler. Three things happen in production that don't happen in the demo:/v1/chat request tries Groq first. If Groq is unavailable (timeout, 5xx, or its circuit is already open), the request transparently retries against Gemini before ever reaching the caller. Each provider has its own in-memory circuit (services/circuit_breaker.py): three consecutive failures trips it to OPEN for 60 seconds, after which one test request is allowed through (HALF_OPEN) to probe recovery. Outcome: a provider outage degrades to "slower, served by the other provider" instead of "every request fails."all-MiniLM-L6-v2 (384 dimensions, normalized) and matched against stored responses by cosine similarity, not exact string match. A rephrased question still hits the cache. Lookup is two-stage: an O(1) SHA-256 hash check for exact repeats, then an HNSW-indexed nearest-neighbor search in Postgres for near-duplicates above a 0.92 similarity threshold. Outcome: cache hits return in ~15-50ms instead of the 1.5-2.5s a live provider call takes, at zero marginal cost.post_process_task) over a Redis broker and executed by a separate worker process. Outcome: request-serving latency is never coupled to database write latency.usage.cost_usd, computed from actual input/output token counts against a per-model pricing table (services/cost.py). Cache hits report $0.00 and accrue against saved_cost_usd on the cache entry instead. Outcome: cost is a first-class field on every logged request, not an estimate reconstructed later from provider invoices./v1/metrics, /v1/logs, /v1/cache/stats, /v1/circuit/states, and /v1/worker/stats every 15 seconds and renders p50/p95/p99 latency, cache hit/miss ratio, per-provider request share and error rate, live circuit breaker state, and a per-request pipeline trace (cache check → provider call → response). Outcome: an operator can see a provider degrading or a circuit opening within 15 seconds, without grepping logs.CircuitBreakerRegistry tracks a rolling failure count and last-failure timestamp per provider and exposes it at /v1/circuit/states and /health, so routing decisions and dashboard alerts are driven by the same source of truth.config.py still carries a database_url SQLite fallback from an earlier iteration, but the live engine (db/database.py) is wired to postgres_url. Celery workers write to the database from a separate process than the API server; SQLite's single-writer lock model doesn't hold up once writes are concurrent and out-of-process. Postgres's MVCC gives every worker and every request its own transaction without blocking the others.0002_requests_cache builds an HNSW index (m=16, ef_construction=64) on cache_entries.embedding, so the ORDER BY embedding <=> :vec LIMIT 1 query in check_cache() resolves in Postgres via approximate nearest-neighbor search — O(log n) — instead of a full table scan./v1/chat returns as soon as it has a response; the DB write and cache-store happen after, via post_process_task.delay(...) over Redis. If those writes were awaited inline, every request's latency would include Postgres round-trip time and, for cache misses, an embedding computation — both irrelevant to what the caller is waiting for.CircuitBreakerRegistry.is_available() short-circuits to False instantly once a provider has failed three times in a row, so failed requests stop paying the timeout tax and go straight to the fallback provider. The 60-second HALF_OPEN probe means recovery is detected without a human resetting anything.services/cache.py as SIMILARITY_THRESHOLD. Below ~0.90, unrelated-but-topically-adjacent prompts ("summarize this contract" vs. "review this contract") start collapsing onto the same cache entry, returning a wrong answer with high confidence. 0.92 was chosen to bias toward precision — a false cache-hit is a silently wrong answer served to a user, which is a worse failure mode than an avoidable cache miss that just costs one extra provider call.circuit:{provider}), with the failure counter incremented via HINCRBY (atomic — correct under concurrent requests across replicas, unlike a read-increment-write) and a short-lived SET NX lock around the OPEN transition so concurrent replicas don't all fire duplicate webhook notifications. Falls back to a local in-memory copy if Redis is unreachable, so a Redis outage degrades circuit accuracy but never blocks provider calls — the added latency is negligible either way, since these checks only run on cache misses, which already cost 1.5-2.5s for the LLM call itself.backend/tests/ for where to add a load-test script; test_gateway.py is currently a placeholder).async/await end to end Async workers Celery (solo pool) Decouples DB writes and cache stores from the request/response cycle; solo pool avoids Windows multiprocessing issues in dev, swapped for prefork in production Message broker Redis Backs the Celery task queue (db 0) and result backend (db 1); also used directly for /v1/worker/stats queue-depth checks Primary database PostgreSQL (asyncpg driver) Concurrent writes from the API process and Celery worker process require a real multi-writer database Vector search pgvector (HNSW index) Stores embeddings as a native Postgres column type and answers nearest-neighbor queries with an index instead of a client-side scan Embedding model sentence-transformers — all-MiniLM-L6-v2 384-dim, CPU-friendly, normalized embeddings suitable for cosine similarity; loaded once per process (optionally warmed at startup) LLM providers Groq (llama-3.1-8b-instant), Gemini (gemini-2.5-flash) Free-tier-friendly, OpenAI-compatible (Groq) and REST (Gemini) APIs used as primary/fallback pair Frontend Next.js 14 (App Router) + Recharts Single dashboard page polling the gateway's own observability endpoints; no separate backend-for-frontend Containerization Docker + Docker Compose backend and frontend each ship their own Dockerfile; see setup notes below on what compose does and doesn't startpip install -r backend/requirements.txt) — the bootstrap script runs Alembic and mints a key directly, outside any containerbackend/.env from .env.example if it doesn't exist yet, starts Postgres + Redis (docker-compose up -d postgres redis), waits for Postgres to report healthy, runs alembic upgrade head (creates api_keys, requests, cache_entries, and the cache's HNSW index — the full schema, in one step), and mints a default API key — printed once.backend/.env and set GROQ_API_KEY and GEMINI_API_KEY (the gateway can't call either provider without them).postgres 5432 PostgreSQL 16 + pgvector redis 6379 Celery broker/backend, API key cache, rate limiting backend 8000 uvicorn app.main:app — FastAPI gateway worker — celery -A app.worker worker --pool=solo — log writes, cache stores, webhook delivery frontend 3000 next start — dashboardapi_keys, requests, cache_entries, plus the cache's HNSW index) — init_db() on backend startup only ensures the vector extension exists, as a safety net."status": "healthy" once Postgres, Redis, and the providers are all reachable — see API reference for the full response shape, and HTTP 503 with "status": "unhealthy" if the database or Redis can't be reached.http://localhost:3000 for the dashboard, and confirm the worker status badge shows LIVE.docker-compose logs -f / docker-compose down.frontend/app/page.tsx calls same-origin /api/gateway/*, which frontend/app/api/gateway/[...path]/route.ts (a Next.js server-side route handler, not a next.config.js rewrite — a rewrite can't add headers) proxies to the backend, injecting Authorization: Bearer $SENTINEL_API_KEY there. The browser never receives that header, so it never shows up in DevTools' network tab or the client JS bundle. Configure it in frontend/.env.local (see frontend/.env.example) — use a low-rate-limit tenant key minted via POST /v1/keys, not the master key, since the dashboard only ever needs read access to metrics/logs.tests/conftest.py spins up a real Postgres+pgvector container via testcontainers (needs Docker) and runs alembic upgrade head against it before any test runs, so integration tests exercise the real schema — not a mock. Redis is faked (fakeredis), and Groq/Gemini calls are monkeypatched (conftest.mock_providers) so the suite has no external dependencies or cost.tests/unit/ — circuit breaker state transitions (and that webhooks fire only on CLOSED→OPEN/→CLOSED, not every failure), rate limiter windowing and Redis-down degradation, API key hashing/rotation/cache invalidation, cost calculation, webhook HMAC signing, health check status rollup rules.tests/integration/ — /v1/chat (cache hit/miss, provider fallback, all-down, auth, rate limiting), /v1/keys CRUD + rotation, /v1/circuit/*, /health*, /v1/webhook/* — all through the real HTTP layer via httpx.ASGITransport.ruff check . lints the same way CI does. Both run automatically in .github/workflows/ci.yml on every push/PR — the CI job needs no external services either, for the same reason (testcontainers + fakeredis).docker-compose.loadtest.yml stack as above:tests/load_test.py is a smaller standalone script — see its docstring./health, /health/live, and /health/ready require Authorization: Bearer <token>. The token is either the master admin key (API_KEY in .env — always valid, required for /v1/keys/*) or a per-tenant key minted via POST /v1/keys (each with its own requests-per-minute limit). See /docs for the full OpenAPI reference, including /v1/keys/* (key management) and /v1/webhook/* (circuit-breaker webhook config/test).POST /v1/chat"bypass_cache": true skips the semantic cache check.GET /v1/logspage (default 1), limit (default 50, max 200), status (success | error | fallback), provider (groq | gemini).GET /v1/metrics1h | 6h | 24h | 7d, default 24h).GET /v1/cache/statsDELETE /v1/cache/invalidateGET /v1/circuit/statesPOST /v1/circuit/{provider}/resetCLOSED — useful when demoing failover recovery without waiting out the 60s timeout.GET /v1/worker/statsGET /v1/pricingGET /health"status": "unhealthy") — built for uptime monitors that check status code, not just body. GET /health/live is a bare liveness probe (always 200 if the process is up); GET /health/ready returns the same payload as /health and is meant for a Kubernetes readiness probe.backend/.env (see backend/.env.example).GROQ_API_KEY Yes "" API key for the primary provider (Groq) GEMINI_API_KEY Yes "" API key for the fallback provider (Gemini) POSTGRES_URL Yes postgresql+asyncpg://sentinel:sentinel_dev_pass@localhost:5432/sentinelai Async SQLAlchemy connection string; this is what the engine actually uses DATABASE_URL No sqlite+aiosqlite:///./sentinelai.db Legacy SQLite URL from an earlier iteration; not used by the active engine API_KEY Yes sentinel-dev-key-123 Master admin key — always valid, required for /v1/keys/*; per-tenant keys are the normal /v1/chat auth path ENVIRONMENT No development Informational — not currently branched on in code CORS_ALLOWED_ORIGINS No http://localhost:3000,http://127.0.0.1:3000 Comma-separated browser origins allowed to call the API GROQ_BASE_URL / GEMINI_BASE_URL No real provider endpoints Override to point at tests/mock_provider.py for load testing LOG_LEVEL No INFO Standard Python logging level; all logs are JSON lines to stdout PRELOAD_EMBEDDING_MODEL No false If true, loads the SentenceTransformer model at process startup instead of on first use LOG_STAGE_TIMINGS No false If true, logs per-stage timing breakdowns (cache_check_ms, groq_ms, gemini_ms, ...) for each /v1/chat callbackend/.env.example for the full list, including the API key cache TTL, default per-key rate limit, circuit-breaker webhook config, and health-check timeouts — every one of them now lives in Settings (app/config.py) rather than being hardcoded in a service file.REDIS_URL/CELERY_BROKER_URL change, no code changes neededprefork pool (the current solo pool is a Windows-dev workaround, single-threaded by design)POST /v1/keys/{id}/rotate exists, but nothing currently prompts a tenant to rotate an old key.github/workflows/ci.yml runs lint, tests, and an image build check on every push; it doesn't push images or deploy anywhere yet, since no deploy target existsservices/circuit_breaker.py) with a same-process in-memory fallback if Redis is down, so it's correct across multiple backend replicas; and the full schema (api_keys, requests, cache_entries, the HNSW index) is Alembic-managed — alembic upgrade head is the only way the schema gets created or changed, Base.metadata.create_all() is gone entirely.Posted Aug 28, 2026
Developed SentinelAI, enhancing API reliability for LLM providers with failover, caching, and observability.