Empty vision response
Raise max_tokens in src/models.ts. Reasoning can consume the budget before any content is produced; use at least 1200 tokens. The primary client already retries an empty reasoning response at 2800.
Local storage, explicit model roles, and a transcript-first integration API. Everything here matches the server in this repository.
Install Bun 1.2 or later, then clone the repository and copy the configuration:
git clone https://github.com/sabbia-ai/sabbia
cd sabbia
cp .env.example .envSet NVIDIA_API_KEY in .env to your provider key, or set SABBIA_MOCK=1 for deterministic offline inference. Then start the single process:
bun run src/index.ts # → http://localhost:3000Open the website or the console. Bun reads .env automatically; process environment variables take precedence. In PowerShell, copy the file with Copy-Item .env.example .env.
From the cloned repository, create .env and configure a key or mock mode before starting:
cp .env.example .env
# Set NVIDIA_API_KEY or SABBIA_MOCK=1 in .env.
docker compose up --build -d
docker compose logs -f sabbiaCompose publishes port 3000 and persists SQLite and uploads in ./data:/data. A keys file needs its own volume mount and a container-relative NVIDIA_KEYS_FILE path. Scanned-PDF OCR requires optional pdftoppm; the base Docker image does not install it.
The public API needs no login. Use HTTPS, visitor cookie isolation, abuse limits, and an optional operator token for destructive routes. The reverse proxy must overwrite X-Forwarded-For with the trusted first client hop and restrict direct listener access.
Every variable in .env.example is listed below. Restart the process after changing configuration.
| Name | Default | Meaning |
|---|---|---|
PORT | 3000 | HTTP listener port; integer from 1 to 65535. |
HOST | 0.0.0.0 | Bind address. Use 127.0.0.1 for a local-only listener. |
SABBIA_DATA_DIR | ./data | SQLite database and content-addressed uploaded files. Must be writable. |
SABBIA_MOCK | 0 | Set to 1 for deterministic offline inference without provider calls. |
NVIDIA_BASE_URL | https://integrate.api.nvidia.com/v1 | OpenAI-compatible provider base URL; all live model calls use this endpoint. |
NVIDIA_API_KEY | Empty | Comma-separated provider keys. Required for live inference unless a keys file supplies them. |
NVIDIA_KEYS_FILE | Empty | Optional path to newline-separated keys; merged with the environment keys and deduplicated. |
SABBIA_MAX_UPLOAD_MB | 25 | Maximum individual uploaded file size in MiB (0.001 to 1024). |
SABBIA_RETRIEVAL_MIN_SCORE | 0.18 | Minimum normalized retrieval score, from 0 to 1. Lower scores are insufficient grounding. |
SABBIA_LOG_LEVEL | info | Minimum JSON log level: debug, info, warn, or error. |
SABBIA_RATE_LIMIT_PER_MIN | 120 | All requests per client IP; token bucket per minute. |
SABBIA_MODEL_RATE_LIMIT_PER_MIN | 6 | Model route admissions per client IP per minute. |
SABBIA_MODEL_RATE_LIMIT_GLOBAL_PER_MIN | 60 | Model route admissions across all clients per minute. |
SABBIA_MODEL_BUDGET_PER_DAY | 1500 | Global model route budget; resets at 00:00 UTC. |
SABBIA_UPLOAD_RATE_PER_HOUR | 10 | Source and asset uploads per client IP per hour. |
SABBIA_MAX_CONCURRENT_STREAMS | 40 | Simultaneous SSE responses across all clients. |
SABBIA_MAX_CONCURRENT_STREAMS_PER_IP | 3 | Simultaneous SSE responses per client IP. |
SABBIA_MAX_UPLOAD_TOTAL_MB | 500 | Total stored upload objects across sources and assets, in MiB. |
SABBIA_MAX_SOURCES | 50 | Maximum sources per corpus. |
SABBIA_RETENTION_DAYS | 14 | Anonymous data retention; hourly cleanup preserves operator rows. |
SABBIA_MAX_JSON_KB | 256 | Maximum JSON request body, in KiB. |
SABBIA_MAX_DATABASE_MB | 512 | SQLite metadata, chunks, events and telemetry ceiling, in MiB. |
SABBIA_AUTH_TOKEN | unset | Optional operator token for the three destructive routes; unset by default. |
The API is open by default. The daily model budget is the cost guard: lower it to tighten spending or raise it for more capacity, then restart. It counts model route admissions, not tokens or dollars, and persists in SQLite across restarts. The dispatcher charges POST thread turns, vision analyses, voice turns, and corpus searches before execution, including local replies and invalid bodies. PDF uploads also reserve a unit because scanned documents can use OCR. Already admitted work can finish. Minute/hour token buckets refill continuously and remove idle clients; maps hold at most 10,000 identities. Use a single process: stream and minute limits are in-process. There is also a 128 concurrent request admission cap and a 100,000 visitor identity cap.
| Status | Meaning |
|---|---|
| 401 | Operator token missing or incorrect on a protected destructive route. |
| 409 | Corpus source count exceeded, or visitor cleanup in progress. |
| 413 | File exceeds the per-file ceiling or JSON body exceeds 256 KiB. |
| 429 | Request, model, upload, stream, daily budget, or identity/concurrency capacity exceeded. application/problem+json names the limit and next availability/reset; numeric Retry-After gives seconds to wait. A spent public demo budget resets at 00:00 UTC. Stream availability uses a five-second retry estimate. |
| 507 | Stored uploads exceed the aggregate quota (detail includes current and maximum bytes), or SQLite reaches its separate metadata ceiling. Shared upload objects count once. Leave disk space for WAL and PDF temporary files as well. |
Search queries longer than 2,000 characters return 400. SSE connections expire after five minutes and close for slow readers with excessive queued events. The console shows friendly inline 429/507 notices and keeps Send disabled while streaming.
Each visitor receives a 32-byte random base64url sabbia_visitor cookie: HttpOnly; Secure; SameSite=Lax, expiring in 30 days. HTTPS is required for public use. API clients must retain and resend it. Only its SHA-256 hash and timestamps are stored; no IP or personal data is recorded. Losing the cookie loses access. Threads, corpora, and assets have visitor ownership. Lists show only the caller's own records. Direct-by-id reads and writes, SSE replay, voice, sources, images, searches, and thread model usage are scoped; another visitor receives 404. Existing rows remain NULL-owned operator data; a valid operator token permits direct access to legacy rows, without granting access to other visitors.
A read-only shared demo corpus (is_public=1), seeded at boot from bundled English and Chinese text, keeps knowledge search working for new visitors who own no corpus. GET /v1/demo/corpus discovers it; the console creates a private corpus on upload.
DELETE /v1/threads/:id, DELETE /v1/sources/:id, and DELETE /v1/voice/sessions/:id are public only while SABBIA_AUTH_TOKEN is unset (local single-user mode). When configured they require Authorization: Bearer or X-API-Key, checked in constant time; otherwise 401. Ownership checks still apply. All other public API access remains without a login. Set the token on a public deployment.
GET /v1/visitor/export returns the caller's threads, turns, corpus metadata, and source titles. DELETE /v1/visitor removes all caller-owned data and clears the cookie without requiring the operator token. Both controls are in the console footer. The hourly retention sweep (also at boot) removes anonymous rows and uploaded objects older than SABBIA_RETENTION_DAYS and logs counts; NULL-owned operator rows are never removed.
The operator can inspect local SQLite access_log records: id, at, visitor_hash, method, route, resource_kind, resource_id, status. Only reads and deletes are audited; no bodies, IPs, or raw cookies. Records are capped at 100,000 and pruned by retention. /healthz reports audit counts, limits (model_remaining_today, model_budget_per_day, streams_open), and storage (used_mb, limit_mb, sources), never raw audit rows. /api/config uses dataDirConfigured instead of a filesystem path. Provider key material and prefixes remain private.
The explicit registry in src/models.ts defines these roles. Provider discovery does not select models. Read the live registry at GET /api/models; a listed model still needs to be enabled for your provider account.
| Role | Model ID | Max tokens | Timeout |
|---|---|---|---|
textMain | openai/gpt-oss-20b | 900 | 60 s |
textFast | nvidia/nemotron-3.5-bolt-e4b | 400 | 30 s |
textBig | nvidia/nemotron-3-ultra-550b-a55b | 1200 | 120 s |
visionMain | nvidia/nemotron-3.5-super-vl-120b-a12b | 1400 | 90 s |
visionAlt | meta/llama-3.2-11b-vision-instruct | 600 | 15 s |
visionLean | meta/muse-glimmer-30b | 600 | 60 s |
guardMain | meta/llama-guard-4-12b | 64 | 20 s |
guardAlt | nvidia/nemotron-3.5-content-safety | 64 | 20 s |
guardJson | nvidia/llama-3.1-nemotron-safety-guard-8b-v3 | 128 | 20 s |
codeFast | poolside/laguna-xs-2.1 | 900 | 60 s |
Reasoning vision models need max_tokens ≥ 1200; with a smaller budget the chain of thought consumes the whole response and the content comes back empty.
The primary vision role normally uses 1400 tokens, enforces a minimum of 1200, and retries once at 2800 if reasoning is present but content is empty. The alternate vision path has a 15-second timeout and one retry before falling back to the primary model. Public image analysis defaults to visionMain.
Send JSON with Content-Type: application/json except for file uploads, which use a multipart field named file. The sketches show representative responses and omit some metadata fields.
| Method | Path | Purpose |
|---|---|---|
| GET | /healthz | Check runtime, database, and provider key health.Request and response sketchRepresentative fields; IDs, results, and timings vary. |
| GET | /api/models | Read the explicit model registry.Request and response sketchRepresentative fields; IDs, results, and timings vary. |
| POST | /v1/threads | Create a conversation on an agent surface.Request and response sketchRepresentative fields; IDs, results, and timings vary. |
| POST | /v1/threads/:id/turns | Submit a message and receive its SSE events.Request and response sketchRepresentative fields; IDs, results, and timings vary. |
| GET | /v1/threads/:id/events | Replay the journal and subscribe to live SSE.Request and response sketchRepresentative fields; IDs, results, and timings vary. |
| POST | /v1/corpora | Create a document collection.Request and response sketchRepresentative fields; IDs, results, and timings vary. |
| POST | /v1/corpora/:id/sources | Upload a document for background ingestion.Request and response sketchRepresentative fields; IDs, results, and timings vary. |
| POST | /v1/corpora/:id/search | Retrieve scored source passages.Request and response sketchRepresentative fields; IDs, results, and timings vary. |
| POST | /v1/assets | Upload an image to the local object store.Request and response sketchRepresentative fields; IDs, results, and timings vary. |
| POST | /v1/vision/analyses | Analyze one image: describe, text, table, or qa.Request and response sketchRepresentative fields; IDs, results, and timings vary. |
| POST | /v1/voice/sessions | Start a transcript session and its voice thread.Request and response sketchRepresentative fields; IDs, results, and timings vary. |
| POST | /v1/voice/sessions/:id/turns | Log a transcript and return the caller turn’s agent reply.Request and response sketchRepresentative fields; IDs, results, and timings vary. |
console, knowledge, support, voice, and vision.corpusId. When omitted, retrieval uses the oldest corpus; Support and Voice also use that default.describe, text, table, and qa. The qa task requires question. Table extraction can return {"text":"…","format":"plain_text"} if structured parsing fails.GET /v1/sources/:id after ingestion; wait for indexed before querying. A duplicate upload returns the existing source with deduplicated: true.application/problem+json with detail and trace_id; the response also includes x-trace-id.Each SSE frame has an id matching its per-thread sequence number and a JSON data payload with v: 1, seq, and kind. The event journal is stored in SQLite.
turn.startblock.opentext.deltacitations.settool.requesttool.resultguardrail.verdictagent.handoffturn.enderrorGET /v1/threads/:id/events?after=<seq>Persist the last processed sequence number and request events strictly after it. You can also send Last-Event-ID; the server uses the larger of that header and after. Replay is followed by live events. Frames include a 3000 ms retry hint, and idle streams receive a heartbeat every 15 seconds. Ignore already processed sequence numbers to avoid duplicates.
POST /v1/threads/:id/turns streams the submitted turn’s events and closes on completion. GET /v1/threads/:id/events stays subscribed.
Raise max_tokens in src/models.ts. Reasoning can consume the budget before any content is produced; use at least 1200 tokens. The primary client already retries an empty reasoning response at 2800.
Function '…' not found for accountThat model is not enabled for your key. Verify the account’s model access; the provider’s discovery list is not an entitlement list.
429The key pool rotates automatically. Check /healthz for usable and cooling keys. Cooldowns start at 5 seconds, double after failures, and cap at 5 minutes. If every key is cooling, wait for retry availability.
failedRead fail_reason on the source using GET /v1/sources/:id. Check the file format and extraction error. An interrupted ingestion is marked failed on restart; remove the source and upload it again.
Voice consumes transcripts. Your telephony stack owns audio transport, speech-to-text (STT), text-to-speech (TTS), and playback interruption. Browser audio capture is outside the product.
POST /v1/voice/sessions and JSON {}. Save its sessionId and threadId.POST /v1/voice/sessions/:id/turns
Content-Type: application/json
{"speaker":"caller","transcript":"I need help with a return.","interrupted":false}The JSON response includes reply, latency_ms, and verdict (allow, redact, refuse, or escalate). Send the returned reply to your TTS service. Caller input and assistant output are checked by the conversation guardrails.
Set interrupted: true when a caller interrupts playback; this marks the previous assistant segment interrupted. An assistant transcript only logs that segment and returns an empty reply with zero latency; it does not invoke a model.
GET /v1/voice/sessions/:id
{"id":"session-id","thread_id":"thread-id","started_at":"2026-10-27T12:00:00.000Z","ended_at":null,
"segments":[{"speaker":"caller","transcript":"I need help with a return.",
"start_ms":1000,"end_ms":2714,"interrupted":0}]}Fetch this session endpoint for persisted segments. Start offsets are measured from session creation; end offsets are estimated from transcript length, not measured audio. Calls are serialized per thread. The Voice transcript console lets you test this flow with text and start a new session.