This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
OpenRag is a modular Retrieval-Augmented Generation (RAG) framework built with FastAPI, Ray for distributed computing, and Milvus as the vector database. It provides document ingestion, chunking, embedding, and retrieval capabilities with an OpenAI-compatible API.
openrag/ # Python package (application code only): core/ services/ api/ di/ prompts/
conf/ # YAML configuration
infra/ # All deployment infrastructure
docker/ # api.Dockerfile, ray.Dockerfile, ui.Dockerfile (build from repo root)
compose/ # docker-compose.yaml + service configs (grafana, prometheus, milvus, .env.example)
scripts/ # entrypoint.sh and other deployment scripts
ansible/ # Ansible playbooks
charts/ # Helm charts (openrag-stack)
cluster.yaml # Ray cluster config
scripts/ # Developer/operational CLI tools (check_layer_imports.py, data_indexer.py, postgres-init/)
tests/ # Integration tests (api_tests/, integration/)
docs/ # Documentation (Astro site + refactoring docs)
ui/ # Admin frontend (React SPA), served by the admin-ui container (infra/docker/ui.Dockerfile)
extern/ # Git submodules + compose service includes
Prompt templates ship inside the package at openrag/prompts/templates/*.txt and are
loaded into DEFAULT_SEEDS by openrag/prompts/__init__.py.
# Install dependencies
uv sync
# Run the application locally (requires Docker services).
# The compose stack and its service configs live under infra/compose/.
cd infra/compose
docker compose up -d # GPU deployment
docker compose --profile cpu up -d # CPU deployment
# Run with rebuild for development
docker compose up --build -d# Run all unit tests (fast, no infra needed)
uv run pytest tests/unit/
# Run a single test file
uv run pytest tests/unit/core/models/test_chunk.py
# Run tests matching a pattern
uv run pytest -k "test_chunk"
# Integration tests (need running services) / load tests
uv run pytest tests/integration/
uv run pytest tests/load/uv run ruff check openrag/ tests/
uv run ruff format openrag/ tests/npm i
npm run dev # Start dev server at http://localhost:4321/openragThe main application entry point is openrag/api/main.py which creates a FastAPI app with Ray initialization.
Ray Actors (distributed components):
Indexer(openrag/services/workers/indexer_pool.py) - Handles document ingestion, chunking, and insertion into vector DBTaskStateManager(openrag/services/workers/task_state.py) - Tracks async task states: QUEUED → SERIALIZING → COMPLETED (or FAILED or CANCELLED)Vectordb/MilvusDB(openrag/services/storage/milvus_store.py) - Vector database operations with hybrid search (dense + BM25 sparse)DocSerializer(openrag/services/workers/parsers/doc_serializer.py) - Serializes files to Document objects using appropriate loadersMarkerPool/MarkerWorker(openrag/services/workers/parsers/marker_workers.py) - Pool of workers for PDF processing with Marker
Pipeline Classes:
RagPipeline(openrag/services/orchestrators/query_service.py) - Orchestrates retrieval and LLM generationRetrieverPipeline(openrag/core/retrieval/pipeline.py) - Handles document retrieval and rerankingRAGMapReduce(openrag/services/orchestrators/query_service.py) - Map-reduce for processing large document sets
- Files uploaded via
POST /indexer/partition/{partition}/file/{file_id}(multipart withfile=@…) Indexer.add_file()serializes file to Document using appropriate loader- Chunker splits document into chunks with contextual metadata
- Embedder generates vectors via VLLM (OpenAI-compatible API)
- Chunks inserted into Milvus with partition-based organization
Each file type has a dedicated loader that converts to markdown:
MarkerLoader(default for PDF, inpdf_loaders/marker.py) - Supports OCR, complex layouts, tablesDocxLoader,PPTXLoader,DocLoader- Office formats (uses MarkItDown library)ImageLoader- VLM-powered image captioningVideoAudioLoader- Audio transcription via WhisperMarkdownLoader,TextLoader(txt_loader.py) - Markdown and plain text files
Loader base class: All loaders inherit from BaseLoader (base.py) which provides:
self.image_captioning- whether image captioning is enabled (use this, notself.config.loader["image_captioning"])self.config- Hydra config accessget_image_description(image_data)- Low-level VLM captioning (accepts PIL Image, HTTP URL, or data URI)caption_images(images, desc)- Caption a list of PIL images concurrently with progress barreplace_markdown_images_with_captions(content, ...)- Find and replace markdown image references with captions- Class regex patterns:
HTTP_IMAGE_PATTERN,DATA_URI_IMAGE_PATTERN
Loader image captioning pattern: Loaders that process images must check self.image_captioning before captioning. Use the shared methods above rather than duplicating captioning logic. Access additional loader config via self.config.loader.get("option_name", default).
Image handling approaches:
- PDF/DOCX/PPTX: Extract binary image data from file, pass to VLM directly
- Markdown: Parse image URLs from text; HTTP URLs require
IMAGE_CAPTIONING_URL=true
The RAG pipeline filters out false-positive sources by having the LLM self-report which sources it actually used:
format_context()(openrag/core/prompts/chat_prompt_builder.py) numbers each source ([Source 1],[Source 2], ...) in the context and returns(formatted_text, included_indices)— the indices track which docs fit within the token budget- Prompt templates (
openrag/prompts/templates/*.txt) instruct the LLM to append[Sources: 1, 3, 5]at the end of its response extract_and_strip_sources_block()(openrag/core/utils/source_filtering.py) strips this tag from the response before sending to the clientfilter_sources_by_citations()(openrag/core/utils/source_filtering.py) filters the source metadata to only include cited sources; if no[Sources: ...]tag is found at all, every presented source is kept instead (a missing tag means the model didn't report citations, not that it used none)- For streaming, the OpenAI router buffers the last 100 chars to catch the sources tag before it reaches the client
The extra field in API responses is a JSON object with these keys. It was a
JSON-encoded string up to and including v2.2.2 — a breaking change for readers
written against the old shape, which must stop calling json.loads on it:
sources— legacy field, kept as-is for existing clients (e.g. Twake): cited sources, or every presented source as a fallback when the model reported no citations — neither a[Sources: ...]tag nor an inline[Source N]marker — or the request skips citation reporting (structured output, direct LLM, web search with no results).presented_sources— every source actually shown to the LLM (afterformat_context()/format_web_context()truncation), regardless of citation. Always present; a client can fall back to this ("sources consulted") when nothing was cited.cited_sources— strictly what the model cited, in the tag or inline[Source N]markers; unlikesources, this never falls back to "everything" — it's[]whenever nothing was cited or citations weren't reported. Chainlit uses this field directly so its source panel never presents uncited retrieval candidates.citations_reported(bool) —truewhen the model reported citations: a[Sources: ...]tag with sources ornone, or inline[Source N]markers (an empty[Sources: ]counts as no tag);falseotherwise, which is exactly whensourcesfalls back to keeping everything. Lets a client tell "the model cited every source" apart from "the model didn't report citations at all".all_retrieved_sources— the complete retrieval set, captured before the context-token-budget truncation, so it also includes documents/web results that didn't fit in the prompt (and, on the map-reduce path, the original retrieved docs rather than the LLM-generated summaries). Only included when the request setsmetadata.include_all_retrieved_sources: true— it's debug/eval telemetry, gated off by default since retrieval is uncapped up toretriever.top_kwhile the context budget only fits a handful of documents.
Each document source entry (build_document_source_link) is shaped:
{
"source_type": "document",
"chunk": { "...the chunk's metadata, copied verbatim..." },
"rerank_score": 0.646,
"chunk_url": "https://host/extract/<chunk id>",
"file_url": "https://host/static/<chunk id>"
}chunk holds what the chunk carries; the siblings are what the server computed about
it. Two consequences worth knowing: source_type, chunk_url and file_url are
authoritative and are scrubbed from chunk as well as overridden at the top level, so
metadata can't spoof them in either place (the guard is structural now, not a manual
overwrite); and file_url is still omitted entirely when the chunk has no source.
Web entries (source_type: "web") are unchanged and flat — url, title, snippet,
no chunk. Clients switch on source_type, as before.
rerank_score — the raw score the reranker gave that chunk — sits beside chunk,
not inside it: it describes how this query ranked the chunk, and the same chunk
retrieved by a different query scores differently.
It gets there via ScoredChunk (openrag/core/models/retrieval_result.py), a Chunk
subclass holding vector_score / rerank_score / combined_score as typed fields.
_rerank_chunks (openrag/core/retrieval/pipeline.py) returns ScoredChunk.from_chunk(...)
instead of the bare chunk, and ScoredChunk.to_langchain() folds the non-null scores into
metadata at the boundary the API response is built from, and build_document_source_link
lifts them back out to sit beside chunk. Because it subclasses Chunk, every
list[Chunk] signature through retrieval, expansion and RRF stays valid. Only
rerank_score is populated today — the vector score is still dropped in
vector_store_searcher._dict_to_chunk (score is in its skip set), and nothing computes
a combined score.
The three key names are shared as RETRIEVAL_SCORE_KEYS (core/utils/consts.py) because
promoting a metadata key to an authoritative sibling is only safe if nothing else can put it
there. Milvus collections have a dynamic field, so upload metadata is persisted verbatim —
{"rerank_score": 0.99} would come back on every read. _dict_to_chunk therefore drops
those keys along with score, and ScoredChunk.to_langchain() clears them from inherited
metadata before stamping its typed fields. A score in an API response was set by that
retrieval, never by whoever uploaded the file.
Three caveats:
- The key is absent, not null, when no reranker ran (reranker disabled, or a web
source — web results are built separately and never reranked). Such chunks stay plain
Chunks and carry no score field at all. - Its scale is provider-dependent: Infinity/vLLM return a
relevance_score, TEI ascorewithraw_scores: false(0–1). Compare within one response, never across deployments, and don't threshold on an absolute value. - On the multi-query path it does not explain the ordering.
get_relevant_docsreranks each sub-query's list separately and then fuses them with RRF, so the final order is the RRF rank; a chunk retrieved by several sub-queries keeps the score from whichever list RRF saw first.
GET /metrics (openrag/api/routers/admin/monitoring.py) serves the default prometheus_client registry: HTTP counters/histogram recorded by api/middleware/instrumentation.py, plus the API-process side of the Tier-1 metrics (openrag_ingest_tasks, inference, tokens, circuit breakers). Anything recorded inside a Ray actor goes through ray.util.metrics instead and is exported by Ray's metrics agent, not /metrics; specs live in core/observability/metric_specs.py, and tests/unit/core/observability/test_metric_emission.py fails the build on a spec nothing writes. Full list and query rules: docs/content/docs/documentation/metrics_reference.md. The path is in DEFAULT_BYPASS_PATHS (no user token needed) and the route enforces its own METRICS_TOKEN (server.metrics_token, blank = unset) via require_metrics_token; admin tokens are deliberately not accepted there — one mechanism, no fallback. It fails closed: token unset and METRICS_ALLOW_UNAUTHENTICATED (server.metrics_allow_unauthenticated) false → 403 on every scrape, with a startup warning from describe_metrics_access. The opt-in exists because the API port is exactly what the Ingress / admin-ui proxy forwards (review on PR #914), so "no token" must never silently mean "open"; a configured token always wins over the opt-in. The admin UI's System > Metrics tab reads GET /monitoring/metrics (admin_router, require_admin, an API prefix) instead — same exposition, separate audience, so the scrape path never touches the Postgres token lookup and an admin never holds the scrape secret. The config is read through load_config() rather than the request container so a scrape keeps working while the container is degraded. Compose: the monitoring overlay writes METRICS_TOKEN into the Prometheus container via a configs.content entry (Compose ≥ 2.23.1) and fails fast without it; the admin-ui nginx returns 404 on /metrics. Helm: openrag.metrics.* (pod annotations + optional ServiceMonitor with bearerTokenFromSecret), env.secrets.METRICS_TOKEN, env.config.METRICS_ALLOW_UNAUTHENTICATED. Docs: docs/content/docs/documentation/prometheus_metrics.md.
Grafana dashboards live once, in infra/charts/openrag-stack/dashboards/ (Helm's .Files.Glob reads only inside the chart): the Compose overlay mounts that directory, and the chart renders one sidecar ConfigMap per file (monitoring.dashboards.*, templates/grafana-dashboards.yaml). monitoring.bundled (default off) installs kube-prometheus-stack as a sub-chart aliased kubePrometheusStack — vllm-stack carries its own, and two same-named sub-charts make Helm coalesce one's defaults into the other — and implies the dashboards, the alert rules (OpenRagTargetDown then leaves the stack's own openrag-monitoring-*/openrag-grafana jobs out) plus the API ServiceMonitor with the bearer, so it refuses to render without METRICS_TOKEN. It also opens the admission-webhook port in the default-deny NetworkPolicy (networkPolicy.webhookFrom, empty = any source): the caller is the API server, which no selector can name. That port is on the webhook's own pod (kubePrometheusStack.prometheusOperator.admissionWebhooks.deployment.enabled: true), not the operator's, whose listener also serves /debug/pprof/. The bundled Grafana's sidecar label/labelValue/folderAnnotation can't be derived from monitoring.dashboards.*, so grafana-dashboards.yaml fails the render when they differ, and when monitoring.dashboards.labels restates the watched label (it would render the key twice, last value wins). Every bundled workload, the certificate hook Jobs included, has requests and a memory limit, no CPU limit (upstream sets none: BestEffort, and a ResourceQuota requiring requests refuses the hook Jobs, failing the install); the numbers come from a kind run, and the Prometheus comment in values.yaml carries the series-count model behind its 1Gi/3Gi. The control-plane targets (kubeControllerManager, kubeScheduler, kubeEtcd, kubeProxy) are off: on a managed control plane their absent(up) alerts fire forever. Its cluster-scoped objects have fixed names, so it is one bundled release per cluster. Two tests render the pinned kube-prometheus-stack archive itself (skipped where it isn't fetched, as in unit CI), so an upstream key renamed by a version bump fails them instead of silently dropping a setting. Under Argo CD the webhook's CA is written by a PostSync hook, which waits for the whole Application to be Healthy, so validation is skipped (failurePolicy: Ignore) until then; admissionWebhooks.certManager.enabled avoids the hooks. Tests: tests/unit/infra/test_monitoring_delivery.py; docs: kubernetes.md, Monitoring.
user/chat.py- OpenAI-compatible/v1/chat/completionsendpointadmin/indexing.py- Document ingestion endpointsuser/search.py- Semantic search endpointsadmin/partitions.py- Partition management (multi-tenant document collections)admin/users.py- User and membership managementadmin/jobs.py- Task queue monitoringadmin/workspaces.py- Workspace CRUD and file managementadmin/tools.py- Tools likeextractTextat/v1/tools/execute(tool param requires JSON:{"name": "extractText"})
The system uses token-based authentication with role-based access control (RBAC) for multi-tenant partition access.
Database Schema (PostgreSQL with SQLAlchemy, in openrag/services/persistence/schema.py):
users- User accounts withid,external_user_id,display_name,token(SHA-256 hashed),is_admin,file_quota,file_count,managed_by_config(provisioned fromauth.seed_users)files- File records withfile_id,partition_name,file_metadata,created_by(FK to users),relationship_id,parent_idpartition_memberships- Join table linking users to partitions with roles (owner,editor,viewer)partitions- Document collections with cascade delete to files and membershipsworkspaces- Named file subsets within a partition for scoped search/chat.workspace_idis unique per partition only ((partition_name, workspace_id)), so every lookup is keyed on both; a workspace-scoped multi-partition search whose id matches several searchable partitions fails withAmbiguousWorkspaceError(422) instead of picking oneworkspace_files- Join table linking workspaces to files by their integer PKs (workspaces.id,files.id), never by the per-partition string ids
Authentication Flow (AuthMiddleware from openrag/api/middleware/auth.py, registered in openrag/api/main.py):
- Token extracted from
Authorization: Bearer <token>header (or?token=query param for/staticroutes) - Token hashed with SHA-256, looked up in database
- User info and accessible partitions set on
request.state.userandrequest.state.user_partitions - Bypassed for:
/docs,/openapi.json,/redoc,/health_check,/version,/chainlit/*— except in OIDC mode the three docs paths (/docs,/redoc,/openapi.json) are login-gated instead of public (see Middleware Behavior below) - If
AUTH_TOKENenv var is not set, defaults to admin user (id=1) for all requests
Role Hierarchy (openrag/services/orchestrators/auth_service.py):
ROLE_HIERARCHY = {"viewer": 1, "editor": 2, "owner": 3}Permission Dependencies (openrag/api/dependencies/auth.py):
require_admin- User must haveis_admin=Truerequire_partition_viewer/require_partition_editor/require_partition_owner- Check partition membership roleSUPER_ADMIN_MODE=trueenv var allows admin users (is_admin=True) to bypass partition checks; regular users remain restricted to their partition memberships
User API Endpoints (/users/):
| Endpoint | Method | Auth | Description |
|---|---|---|---|
/users/ |
GET | Admin | List all users |
/users/info |
GET | Any | Get current user info |
/users/ |
POST | Admin | Create user (returns token once) |
/users/{user_id} |
DELETE | Admin | Delete user (cannot delete id=1) |
/users/{user_id}/regenerate_token |
POST | Admin/self | Regenerate API token |
/users/{user_id}/quota |
PATCH | Admin | Update user file quota |
Partition Membership Endpoints (/partition/{partition}/users):
| Endpoint | Method | Auth | Description |
|---|---|---|---|
/partition/{partition}/users |
GET | Owner | List partition members |
/partition/{partition}/users |
POST | Owner | Add user with role |
/partition/{partition}/users/{user_id} |
DELETE | Owner | Remove user |
/partition/{partition}/users/{user_id} |
PATCH | Owner | Update user role |
Core Implementation (PartitionFileManager in openrag/services/persistence/partition_repo.py):
# User operations (called via MilvusDB Ray actor)
await vectordb.create_user.remote(display_name="Name", is_admin=False)
await vectordb.get_user_by_token.remote(token)
await vectordb.regenerate_user_token.remote(user_id)
# Membership operations
await vectordb.add_partition_member.remote(partition, user_id, role="editor")
await vectordb.update_partition_member_role.remote(partition, user_id, "owner")
await vectordb.list_partition_members.remote(partition)Token Format: "or-" + secrets.token_hex(16) (34-char string, shown only once on creation/regeneration)
Bootstrap: On startup, ensures admin user (id=1) exists using AUTH_TOKEN env var or generates a random token.
Seed users (#1153): auth.seed_users (or the SEED_USERS env var, rendered by the chart from openrag.seedUsers) declares operator-managed accounts, provisioned by PgUserRepository.ensure_seed_users at API startup only, with each token read from the env var the entry names. Seeding only upserts rows it created (users.managed_by_config) and listed memberships; it never deletes, and a removed entry just has its token revoked. Full semantics: docs/content/docs/documentation/user_auth.md.
Multi-Partition Search: Users can search across all their accessible partitions:
- Search endpoint:
GET /search?partitions=all&text=query - Chat completions:
POST /v1/chat/completionswith"model": "openrag-all" - For regular users,
allresolves to their partition memberships only - For admins with
SUPER_ADMIN_MODE=true,allresolves to all system partitions - Model prefix is
openrag-(legacy:ragondin-)
Optional web search augmentation via the Staan API, allowing the LLM to combine RAG document context with live web results.
Configuration (conf/config.yaml → websearch: block, env vars):
WEBSEARCH_API_TOKEN— provider API token; if unset, web search is silently disabledWEBSEARCH_BASE_URL— provider endpoint (default: Staan API)WEBSEARCH_TOP_K— number of web results (default: 5)WEBSEARCH_LANG— search language/market (default:fr-FR)
How it works:
- Client sends
metadata: {"websearch": true}in the chat completion request - Combined mode (partition + websearch): RAG retrieval and web search run concurrently via
asyncio.gather(); web results are appended after document sources with continuous[Source N]numbering - Web-only mode (no partition + websearch): skips RAG retrieval entirely, uses web results as sole context; if no results (token unset / search fails), falls back to plain direct LLM mode
- Source entries include
source_type: "document"orsource_type: "web"in theextra.sourcesresponse
Key files:
openrag/services/websearch/—WebSearchService(service.py),BaseWebSearchProvider(base.py),StaanProvider(providers/staan.py)openrag/core/prompts/chat_prompt_builder.py—format_web_context()formats web results as numbered source blocksopenrag/services/orchestrators/query_service.py—_prepare_for_web_only(), web search logic in_prepare_for_chat_completion()openrag/api/routers/user/chat.py—__prepare_sources()merges document and web sources
Indexing is asynchronous, so a client can either poll the task-status URL or hand OpenRag a URL to notify once the task settles.
Request fields (multipart form, on POST and PUT /indexer/partition/{partition}/file/{file_id}):
callback_url— POSTed once the task reaches a terminal statecallback_token— sent asAuthorization: Bearer <token>on that POST
Body: {"partition", "file_id", "status": "success"|"error", "metadata"}. metadata is
the upload metadata echoed back minus UPLOAD_METADATA_SERVER_KEYS (core/utils/consts.py:
source, filename, original_filename, file_size, file_id, content_sha256) — an exclusion,
not a fixed field list, so any caller-supplied field (cozy-stack's revision marker is doc_rev)
travels through unrecomputed and under whatever name the caller gave it; a key the caller never sent
is simply absent, not echoed back as null. The exclusion exists because the target is a
caller-supplied URL and _build_metadata merges those server-computed keys into the same dict —
source (the server's on-disk path) is the load-bearing one. test_build_metadata_only_adds_keys_in_upload_metadata_server_keys
(tests/unit/services/orchestrators/test_indexing_service.py) fails the build if _build_metadata
ever injects a key the constant doesn't cover.
Guarantees: one attempt, no retries, a 5 s deadline on the request, never raises — a failed
callback is logged and cannot change the indexing outcome. No callback_url → strict no-op. A
user-cancelled task sends nothing (only a real failure notifies "error"), which is why the sender
keys off the return value of set_failed_if_not_cancelled and the pool's pre-flight handler catches
Exception, not BaseException.
Not guaranteed: delivery. The send is awaited on the worker's slot, so a blackholing target costs up to 5 s of indexing throughput per file, and an actor lost before the task settles notifies nothing at all. Clients keep a timeout and fall back to polling the task-status URL.
callback_url is checked against the SSRF guard (is_safe_url) in the router and again in the
sender. The literal check covers the scheme, loopback/private/link-local and reserved addresses,
alternate numeric IPv4 spellings, IDNA-equivalent separators, and a single DNS root dot. DNS
hostnames are not resolved by this callback check; use network egress controls when hostname
destinations must be restricted. HTTP and HTTPS are both allowed.
INDEXING_CALLBACK_ALLOW_PRIVATE_URLS=true / indexing_callback.allow_private_urls lifts the
address half of the guard for dev stacks whose target is a local instance; the scheme check always
applies. Keep it off in production: with it on, any user allowed to upload can make the server POST
to an internal address.
Rolling deploys: the worker actors are named, detached and get_if_exists, so changing
process_file's remote contract requires bumping _INDEXER_ACTOR_PROTOCOL_VERSION in
indexer_pool.py (v3 → v4 for callback_url/callback_token; v5 added worker-ref-registration wait
and TSM set_state fencing; v7 folds in a second, independent v6 lineage — STT-preset-aware registry
hydration plus the _active_indexation_config contextvar — that landed on develop under the same
version string while this branch's own v6 was in flight; v8 (develop) added max_restarts on the
dispatcher and workers, since Ray only applies actor options when it creates the actor and
get_if_exists=True would otherwise silently keep the previous release's restart policy (#846); v8
(this branch, independently) covers TaskStateManager bounding its in-memory retention and being
replaced during bootstrap when an older actor lacks that support — that replacement changes the
TaskStateManager actor id, which strands the dispatcher's and workers' cached handles to it unless
the whole generation rolls together; v9 folds in both independent v8 lineages). Without the bump, new
replicas attach to the previous release's actors and every submit raises TypeError. Old generations
are retired with services/workers/retire_indexer_generation.py.
Key files:
openrag/services/workers/indexing_callback.py—send_indexing_callback()(waswebhook.py; the target is a normal authenticated route now, not an unauthenticated webhook trigger)openrag/core/utils/url_safety.py—is_safe_url(url, *, allow_private_hosts=False), shared with the MCPindex_urltool (which keeps the strict default). The web-search content fetcher (services/websearch/content_fetcher.py) does not import this — it has its own older, un-synced_is_safe_url, so hardening this module does not automatically harden that one.openrag/core/config/indexation.py—IndexingCallbackConfig
Both fields travel the same chain as the rest of an indexing job: api/routers/admin/indexing.py →
services/orchestrators/indexing_service.py → core/indexing/dispatcher.py (port) →
services/workers/dispatcher.py → indexer_pool.py → indexer_actor.py → indexing_callback.py.
Per-user file quota enforcement tracked via the file_count and file_quota columns on users, and created_by on files.
How it works:
files.created_byrecords which user uploaded each file (nullable for pre-migration files)users.file_countis incremented/decremented in application code (inPartitionFileManager) — no SQL triggers- Decrements use
func.greatest(file_count - N, 0)to prevent negative values from race conditions delete_partitionqueries per-uploader counts before cascade delete, then bulk decrements- Quota check (
check_user_file_quotainopenrag/api/dependencies/auth.py) runs on upload, considering both indexed files and pending tasks
Quota logic (file_quota column):
None→ use global default (DEFAULT_FILE_QUOTAenv var, default-1)< 0→ unlimited>= 0→ specific limit- Admins always bypass quota checks
Key design decisions:
- Counts are tracked per uploader (whoever calls the upload API), not per partition owner
created_byusesondelete="SET NULL"so deleting a user doesn't cascade-delete their filesIndexer.delete_fileandMilvusDB.delete_file/delete_partitiondon't need auser_idparameter — the uploader is looked up fromfiles.created_by
Migration: openrag/services/persistence/migrations/alembic/versions/c224d4befe71_add_file_count_and_file_quota.py
Base.metadata.create_all() runs at app startup (PartitionFileManager.__init__ in openrag/services/persistence/partition_repo.py), so a freshly bootstrapped database already contains the full current-model schema before alembic ever touches it. Migrations must therefore be idempotent — re-applying an ADD COLUMN / CREATE TABLE / CREATE INDEX against an already-existing object would raise DuplicateColumn / DuplicateTable.
Guard every schema-mutating op with an inspector-based existence check (table_exists, column_exists, index_exists, fk_exists), in both upgrade() and downgrade(). For migrations that convert a column type, also short-circuit if the column is already the target type.
Configuration is a single YAML file validated with Pydantic models:
- Main config:
conf/config.yaml - Loaded by
openrag/core/config/loader.py(load_config()exposed fromopenrag/core/config/__init__.py) - Pydantic config classes live in
openrag/core/config/(root.py,auth.py,chunking.py,retrieval.py,indexation.py,endpoints.py,mcp.py,infrastructure.py,base.py)
Environment variables override config values (see infra/compose/.env.example).
All tests live in a separate tests/ tree (zero test files inside the openrag/ package):
- Unit tests:
tests/unit/**/test_*.py(pytest, mirrors the package structure; no external services needed) - Integration tests:
tests/integration/api/*.py(HTTP endpoint tests, requires running server) andtests/integration/repos/*.py(repo/store tests) - Robot Framework tests:
tests/integration/robot/api/*.robot - Load/benchmark tests:
tests/load/ - Shared fixtures:
tests/unit/conftest.py(mock ports),tests/unit/api/conftest.py(ASGI client), plus per-suite conftests - Test config lives in
pyproject.toml([tool.pytest.ini_options]):testpaths = ["tests"],pythonpath = ["./openrag"], and theenvblock setsPROMPTS_DIR=./openrag/prompts/templates
Running integration tests locally with act:
# Run API tests using GitHub Actions locally
act -j api-tests -W .github/workflows/api_tests.yml --bindMock VLLM for CI: tests/api_tests/api_run/mock_vllm.py provides fake embeddings and completions endpoints (streaming and non-streaming) for testing without a real LLM. Pydantic request models use ConfigDict(extra="allow") to accept vendor-specific fields like extra_body.
# Get actor references
vectordb = ray.get_actor("Vectordb", namespace="openrag")
indexer = ray.get_actor("Indexer", namespace="openrag")
task_state_manager = ray.get_actor("TaskStateManager", namespace="openrag")
# Call remote methods
await vectordb.async_search.remote(query=query, partition=partition)Use the centralized utility for calling Ray actors with proper timeout and cancellation handling:
from services.workers.ray_utils import call_ray_actor_with_timeout
result = await call_ray_actor_with_timeout(
future=actor.method.remote(args),
timeout=TIMEOUT_SECONDS,
task_description="Description for error messages",
)This handles:
- Timeout with
ray.wait()andray.cancel() asyncio.CancelledErrorpropagationRayTaskErrorandTaskCancelledErrorhandling
All custom exceptions inherit from OpenRAGError (openrag/core/utils/exceptions.py):
VDBErrorsubclasses for vector database errorsEmbeddingErrorfor embedding failures
Uses Loguru with structured logging:
from core.utils.logging import get_logger
logger = get_logger()
logger.bind(file_id=file_id, partition=partition).info("Message")stderr is the only sink (core/utils/logging.py); there is no log file. LOG_FORMAT=text (default) is the colorized terminal format, LOG_FORMAT=json writes one flat JSON object per line (json_record/json_sink: ts, level, logger, function, line, msg, exception, then every bound extra at the top level, collisions prefixed extra_) and routes stdlib logging (uvicorn, Ray) through loguru via InterceptHandler (idempotent intercept_stdlib_logging(level): one interceptor on the root, foreign handlers detached not closed, root at loguru's numeric level so libraries' isEnabledFor(DEBUG) guards hold, chatty loggers capped at WARNING only while still at NOTSET). RequestIdMiddleware binds request_id into the loguru context for the whole request; the unhandled-500 handler binds it explicitly because it runs after that scope unwinds. Ray relays worker output onto the API stream with a (Actor pid=N) prefix that the collector strips; RAY_DEDUP_LOGS=0 and RAY_COLOR_PREFIX=0 are required for that relay to carry valid JSON (overlay and chart set them), and with ray.enabled=true the chart sets RAY_LOG_TO_STDERR=1 so workers write to their own pod's stderr (the relay only carries the current driver job's actors). Shipping: compose infra/compose/logging.docker-compose.yaml (Alloy, infra/compose/alloy/config.alloy), Helm sets LOG_FORMAT=json and relies on the platform DaemonSet. Docs: docs/content/docs/documentation/loki_logs.md.
Use absolute imports from the openrag/ directory (which is the Python path root):
# Correct - absolute imports
from services.workers.ray_utils import call_ray_actor_with_timeout
from core.utils.logging import get_logger
from core.config import load_config
# Avoid relative imports across packages
# from .ray_utils import ... # Only within same packageOpenRag supports two authentication modes, controlled by the AUTH_MODE environment variable:
Token Mode (AUTH_MODE=token, default):
- Bearer token authentication via
Authorization: Bearer <AUTH_TOKEN>header - Existing behavior unchanged
- Suitable for programmatic access, CI/CD, and testing
- Admin user (id=1) created with
AUTH_TOKENenv var or random token on bootstrap
OIDC Mode (AUTH_MODE=oidc):
- OpenID Connect Authorization Code + PKCE flow
- Users authenticate via an external IdP (Keycloak, LemonLDAP::NG, etc.)
- Browser UI (Chainlit, Indexer) redirects to IdP login
- Opaque session tokens stored in
openrag_sessionhttpOnly cookie - Bearer
users.tokenstill accepted for programmatic access
Env Variables (required when AUTH_MODE=oidc):
| Variable | Purpose | Example |
|---|---|---|
OIDC_ENDPOINT |
Issuer URL for auto-discovery | https://idp.example.com/realms/openrag |
OIDC_CLIENT_ID |
Client registered at IdP | openrag |
OIDC_CLIENT_SECRET |
Client secret | (provided by IdP) |
OIDC_REDIRECT_URI |
Callback URL — the front door that serves the UI and reaches the backend /auth/callback (the admin-ui / proxy port, not necessarily APP_PORT); must match IdP config |
https://openrag.example.com/auth/callback |
OIDC_TOKEN_ENCRYPTION_KEY |
Fernet key for token encryption | (generate via: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())") |
Optional Env Variables:
| Variable | Default | Purpose |
|---|---|---|
OIDC_CLAIM_SOURCE |
id_token |
Where to read claims for claim mapping: id_token (verified JWT) or userinfo (/userinfo endpoint) |
OIDC_CLAIM_MAPPING |
(none) | CSV of db_field:claim pairs to sync IdP claims into the users row on every login (whitelist: display_name, email). Unset = no post-login update. |
OIDC_SCOPES |
openid email profile offline_access |
Space-separated scope list (include offline_access for refresh tokens) |
OIDC_POST_LOGOUT_REDIRECT_URI |
— | URL the IdP sends the user to after RP-initiated logout. No default (an OpenRag URL would re-trigger OIDC login) |
OIDC_AUTO_PROVISION_LOGIN |
false |
When true, an unknown sub triggers on-the-fly creation of a non-admin user from the ID-token claims (name/preferred_username → display_name, email → email). Default keeps the strict admin-pre-provisioning policy below. |
User Matching & Provisioning:
When a user logs in via OIDC, matching is exclusively by users.external_user_id == sub (the stable OIDC claim). There is no email fallback. If the sub is unknown, the callback either:
- returns
403 "User not registered"(default — admins must pre-create every user), or - creates a non-admin user from the ID-token claims when
OIDC_AUTO_PROVISION_LOGIN=true. Auto-provisioned users inherit the default file quota;is_adminis alwaysfalse(operators can promote afterwards via/users/{id}or/users/).
Optionally, if OIDC_CLAIM_MAPPING is set, after a successful match the callback reads the configured claims (from the ID token or /userinfo, per OIDC_CLAIM_SOURCE) and updates the user row. The writable whitelist is strict — only display_name and email are allowed; is_admin, external_user_id, file_quota, token are never writable via claim mapping.
Admin Pre-provisioning: Admins create users with the external_user_id matching the IdP's sub claim for that user. Example:
curl -X POST http://localhost:8080/users/ \
-H "Authorization: Bearer <AUTH_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"display_name": "Alice", "external_user_id": "kc-alice-uuid", "is_admin": false}'Database Schema:
Columns on users table relevant to OIDC:
external_user_id(String, unique, nullable): Must equal the IdP'ssubfor OIDC matchingemail(String, unique, nullable): Pure metadata; populated manually or via claim mapping. Not used for matching.
New table oidc_sessions:
session_token_hash(unique): SHA-256 of the opaque session tokenuser_id(FK): User this session belongs tosid(nullable): OIDC session identifier (used for back-channel logout)sub(required): OIDCsubclaim (stable user identifier)id_token_encrypted,access_token_encrypted,refresh_token_encrypted: Fernet-encrypted IdP tokensaccess_token_expires_at,session_expires_at: Token expiry timesrevoked_at(nullable): Set on back-channel logout or manual revocation
Auth Endpoints (all bypass the normal middleware):
| Method | Path | Purpose |
|---|---|---|
| GET | /auth/login |
Start Authorization Code + PKCE flow; redirects to IdP |
| GET | /auth/callback |
IdP callback; creates session, sets cookie; redirects to next_url |
| POST | /auth/backchannel-logout |
IdP-driven logout (OIDC spec); revokes sessions by sid |
| GET | /auth/logout |
RP-initiated logout; invalidates session + redirects to IdP |
| GET | /auth/me |
(debug) Returns current user and session expiry |
Session Management:
- Session token: URL-safe opaque token (
secrets.token_urlsafe(32)— ~43 chars from 32 bytes of randomness), hashed (SHA-256) before storage - Cookie:
openrag_session(httpOnly, Secure if HTTPS, SameSite=Lax, Path=/, no Domain=) - TTL: Aligned with
access_token_expires_at; auto-refresh ifrefresh_tokenavailable (<60s before expiry) - Revocation: Via back-channel logout or manual invalidation
Middleware Behavior:
- UI paths (
/,/chainlit,/static) in OIDC mode without auth → 302 redirect to/auth/login?next=... - Interactive docs (
/docs,/redoc,/openapi.json): public in token mode (bypassed), but login-gated in OIDC mode — an unauthenticated browser is 302-redirected to/auth/login; a valid session renders them. This stops the full API surface + schema from being served anonymously in production. The set isAuthBypassConfig.oidc_gated_paths(default("/docs", "/redoc", "/openapi.json")); override it to()to keep docs public under OIDC. - API paths (
/v1,/indexer,/search, etc.) without auth:- Token mode →
403 {"detail": "Missing token"}(no bearer) or403 {"detail": "Invalid token"}(unknown bearer). The 403 status is a legacy contract the robot suite asserts (tests/api/). - OIDC mode →
401 {"detail": "Unauthenticated"}(no usable session/bearer and the path isn't a UI redirect target).
- Token mode →
- Programmatic access: Bearer
users.tokenaccepted in both modes
See Also: Full configuration and troubleshooting guide at docs/content/docs/documentation/oidc.md (quick start: docs/content/docs/documentation/sso-quickstart.md).