Merge remote-tracking branch 'origin/main' into branding-upd

# Conflicts:
#	docsgpt/core/models/anthropic.yaml
#	docsgpt/core/models/openai.yaml
#	frontend/src/admin/AdminUI.tsx
This commit is contained in:
Alex committed 2026-09-21 17:52:53 +01:00
commit a25bec6821
300 files changed
+27029 -1745

No files matched your search

+1 -1
View File
@@ -15,7 +15,7 @@ have to know which driver a given field feeds. Each normalizer also
silently upgrades the legacy ``postgresql+psycopg2://`` prefix since
psycopg2 is no longer in the project.
This module is deliberately separate from ``docsgpt/core/settings.py``
This module is deliberately separate from ``docsgpt/core/settings``
so the Settings class stays focused on field declarations, and the
URI-rewriting logic can be unit-tested without triggering ``.env``
file loading from importing Settings.
+1 -1
View File
@@ -140,7 +140,7 @@ class ModelRegistry:
from docsgpt.llm.providers import ALL_PROVIDERS
directories = [BUILTIN_MODELS_DIR]
operator_dir = getattr(settings, "MODELS_CONFIG_DIR", None)
operator_dir = settings.MODELS_CONFIG_DIR
if operator_dir:
op_path = Path(operator_dir)
if not op_path.exists():
+9 -2
View File
@@ -32,8 +32,15 @@ class ModelCapabilities:
supports_streaming: bool = True
supported_attachment_types: List[str] = field(default_factory=list)
context_window: int = 128000
input_cost_per_token: Optional[float] = None
output_cost_per_token: Optional[float] = None
# USD per 1M tokens; consumed by ``docsgpt/pricing.py``. ``None`` means
# "not declared": the call is recorded at $0 unless
# ``QUOTA_UNPRICED_RATE_PER_MILLION`` is set.
input_cost_per_million: Optional[float] = None
output_cost_per_million: Optional[float] = None
# Rates for the prompt-cache sub-bins of the prompt total. ``None`` bills
# those tokens at ``input_cost_per_million``.
cached_input_cost_per_million: Optional[float] = None
cache_write_cost_per_million: Optional[float] = None
# OpenAI reasoning-model effort hint (none/minimal/low/medium/high/xhigh;
# the accepted subset is model-dependent). Consumed by OpenAILLM — sent
# top-level on Chat Completions and nested under ``reasoning`` on the
+29 -5
View File
@@ -18,7 +18,7 @@ from pathlib import Path
from typing import Dict, List, Optional, Sequence
import yaml
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from docsgpt.core.model_settings import (
AvailableModel,
@@ -65,11 +65,33 @@ class _CapabilityFields(BaseModel):
supports_streaming: Optional[bool] = None
attachments: Optional[List[str]] = None
context_window: Optional[int] = None
input_cost_per_token: Optional[float] = None
output_cost_per_token: Optional[float] = None
input_cost_per_million: Optional[float] = Field(default=None, ge=0)
output_cost_per_million: Optional[float] = Field(default=None, ge=0)
cached_input_cost_per_million: Optional[float] = Field(default=None, ge=0)
cache_write_cost_per_million: Optional[float] = Field(default=None, ge=0)
reasoning_effort: Optional[str] = None
api_flavor: Optional[str] = None
@model_validator(mode="before")
@classmethod
def _per_token_alias(cls, data):
"""Accept the deprecated ``*_cost_per_token`` keys, scaled to per-1M."""
if not isinstance(data, dict):
return data
data = dict(data)
for side in ("input", "output"):
old, new = f"{side}_cost_per_token", f"{side}_cost_per_million"
if old not in data:
continue
value = data.pop(old)
if new in data:
raise ValueError(f"set only one of {old} and {new}")
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(f"{old} must be a number")
logger.warning("%s is deprecated; use %s (USD per 1M tokens)", old, new)
data[new] = value * 1_000_000
return data
@field_validator("reasoning_effort")
@classmethod
def _valid_reasoning_effort(cls, v: Optional[str]) -> Optional[str]:
@@ -237,8 +259,10 @@ def _build_model(
supports_streaming=pick("supports_streaming", True),
supported_attachment_types=expanded,
context_window=pick("context_window", 128000),
input_cost_per_token=pick("input_cost_per_token", None),
output_cost_per_token=pick("output_cost_per_token", None),
input_cost_per_million=pick("input_cost_per_million", None),
output_cost_per_million=pick("output_cost_per_million", None),
cached_input_cost_per_million=pick("cached_input_cost_per_million", None),
cache_write_cost_per_million=pick("cache_write_cost_per_million", None),
reasoning_effort=pick("reasoning_effort", None),
api_flavor=pick("api_flavor", "chat_completions"),
)
+4 -2
View File
@@ -108,8 +108,10 @@ defaults: # optional, applied to every model below
supports_streaming: bool # default true
attachments: [<alias-or-mime>, ...] # default []
context_window: int # default 128000
input_cost_per_token: float # default null
output_cost_per_token: float # default null
input_cost_per_million: float # USD per 1M prompt tokens; default null (unpriced)
output_cost_per_million: float # USD per 1M generated tokens; default null
cached_input_cost_per_million: float # prompt-cache reads; default: the input rate
cache_write_cost_per_million: float # prompt-cache writes; default: the input rate
reasoning_effort: <string> # default null; none|minimal|low|medium|high|xhigh (subset is model-dependent)
api_flavor: <string> # chat_completions (default) or responses
+12
View File
@@ -10,17 +10,29 @@ models:
description: Most capable Claude model for complex reasoning and agentic coding
context_window: 1000000
supports_structured_output: true
input_cost_per_million: 5.0
output_cost_per_million: 25.0
cached_input_cost_per_million: 0.5
cache_write_cost_per_million: 6.25
- id: claude-sonnet-4-6
display_name: Claude Sonnet 4.6
description: Best balance of speed and intelligence with extended thinking
context_window: 1000000
supports_structured_output: true
input_cost_per_million: 3.0
output_cost_per_million: 15.0
cached_input_cost_per_million: 0.3
cache_write_cost_per_million: 3.75
- id: claude-haiku-4-5
display_name: Claude Haiku 4.5
description: Fastest Claude model with near-frontier intelligence
supports_structured_output: true
input_cost_per_million: 1.0
output_cost_per_million: 5.0
cached_input_cost_per_million: 0.1
cache_write_cost_per_million: 1.25
- id: claude-fable-5
display_name: Claude Fable 5
+4
View File
@@ -12,7 +12,11 @@ models:
- id: deepseek-v4-flash
display_name: DeepSeek V4 Flash
description: Cost-efficient 1M-context model with hybrid thinking / non-thinking modes, tool calling and FIM completion
input_cost_per_million: 0.14
output_cost_per_million: 0.28
- id: deepseek-v4-pro
display_name: DeepSeek V4 Pro
description: Frontier 1M-context model with hybrid thinking / non-thinking modes for advanced reasoning and agentic coding
input_cost_per_million: 0.435
output_cost_per_million: 0.87
+3
View File
@@ -7,3 +7,6 @@ models:
supports_tools: true
attachments: [image]
context_window: 1048576
input_cost_per_million: 0.15
output_cost_per_million: 0.5
cached_input_cost_per_million: 0.03
+7
View File
@@ -9,9 +9,16 @@ models:
- id: gemini-3.1-pro-preview
display_name: Gemini 3.1 Pro (preview)
description: Most capable Gemini 3 model with advanced reasoning and agentic coding (preview)
# Priced at the >200k-token tier; long prompts are common with attachments.
input_cost_per_million: 4.0
output_cost_per_million: 18.0
- id: gemini-3.5-flash
display_name: Gemini 3.5 Flash
description: Frontier-class Flash for sustained performance on agentic and coding tasks
input_cost_per_million: 1.5
output_cost_per_million: 9
- id: gemini-3.1-flash-lite
display_name: Gemini 3.1 Flash-Lite
description: Cost-efficient frontier-class multimodal model for high-throughput workloads
input_cost_per_million: 0.25
output_cost_per_million: 1.5
+7
View File
@@ -8,9 +8,16 @@ models:
display_name: GPT-OSS 120B
description: OpenAI's open-weight 120B flagship served on Groq's LPU hardware; strong general reasoning with strict structured output support
supports_structured_output: true
input_cost_per_million: 0.15
output_cost_per_million: 0.6
cached_input_cost_per_million: 0.075
- id: llama-3.3-70b-versatile
display_name: Llama 3.3 70B Versatile
description: Meta's Llama 3.3 70B for general-purpose chat with parallel tool use
input_cost_per_million: 0.59
output_cost_per_million: 0.79
- id: llama-3.1-8b-instant
display_name: Llama 3.1 8B Instant
description: Small, very low-latency Llama model (~560 tok/s) with parallel tool use
input_cost_per_million: 0.05
output_cost_per_million: 0.08
+6
View File
@@ -8,14 +8,20 @@ models:
display_name: DeepSeek V4 Pro
description: 1.6T MoE (49B active) with 1M context, hybrid CSA/HCA attention, top-tier reasoning and agentic coding
context_window: 1048576
input_cost_per_million: 1.6
output_cost_per_million: 3.2
- id: moonshotai/kimi-k2.6
display_name: Kimi K2.6
description: 1T-parameter open-weight MoE with native vision/video, multi-step tool calling, and agentic long-horizon execution
attachments: [image]
context_window: 262144
input_cost_per_million: 0.8
output_cost_per_million: 3.4
- id: zai-org/glm-5
display_name: GLM-5
description: Z.AI 754B-parameter MoE with strong general reasoning, function calling, and structured output
context_window: 202800
input_cost_per_million: 1.0
output_cost_per_million: 3.2
+10
View File
@@ -12,12 +12,22 @@ models:
context_window: 1050000
api_flavor: responses
reasoning_effort: medium
# Short-context rates. Prompts over 272K tokens bill at $10 / $45 (cached $1).
input_cost_per_million: 5.0
output_cost_per_million: 30.0
cached_input_cost_per_million: 0.5
- id: gpt-5.4-mini
display_name: GPT-5.4 Mini
description: Cost-efficient GPT-5.4-class model for high-volume coding, computer use, and subagent workloads
input_cost_per_million: 0.75
output_cost_per_million: 4.5
cached_input_cost_per_million: 0.075
- id: gpt-5.4-nano
display_name: GPT-5.4 Nano
description: Cheapest GPT-5.4-class model, optimized for simple high-volume tasks where speed and cost matter most
input_cost_per_million: 0.2
output_cost_per_million: 1.25
cached_input_cost_per_million: 0.02
- id: gpt-5.6-sol
display_name: GPT-5.6 Sol
description: Latest GPT-5.6 generation flagship for long-horizon reasoning and agentic work
+10 -1
View File
@@ -10,16 +10,25 @@ models:
description: Free-tier 480B MoE coder model with strong agentic tool use; rate-limited
context_window: 262000
attachments: []
input_cost_per_million: 0.0
output_cost_per_million: 0.0
- id: deepseek/deepseek-v3.2
display_name: DeepSeek V3.2
description: Open-weights reasoning model, very low cost (~$0.25 in / $0.38 out per 1M)
description: Open-weights reasoning model, very low cost (~$0.27 in / $0.40 out per 1M)
context_window: 131072
attachments: []
supports_structured_output: true
input_cost_per_million: 0.269
output_cost_per_million: 0.4
cached_input_cost_per_million: 0.1345
- id: anthropic/claude-sonnet-4.6
display_name: Claude Sonnet 4.6 (via OpenRouter)
description: Frontier Sonnet-class model with 1M context, vision, and extended thinking
context_window: 1000000
supports_structured_output: true
input_cost_per_million: 3.0
output_cost_per_million: 15.0
cached_input_cost_per_million: 0.3
cache_write_cost_per_million: 3.75
+16 -6
View File
@@ -5,15 +5,18 @@ ships with it (prompts, model catalogs, migrations). A checkout, when the
package is imported from one, is the directory holding ``pyproject.toml``.
The data home is where runtime data lives: ``.env``, ``inputs``, ``indexes``.
The home is ``DOCSGPT_HOME`` when set, else the checkout, else the current
directory. That keeps a source checkout and the Docker image (which runs from
``/app`` with the package beside it) behaving as before, and gives a
``pip install docsgpt`` user a home that is not ``site-packages``.
The home is ``DOCSGPT_HOME`` when set, else the checkout, else the default
home (``~/.docsgpt/server``, or ``/opt/docsgpt`` for root on Linux). That keeps
a source checkout and the Docker image (which pins ``DOCSGPT_HOME=/app``)
behaving as before, and gives an installed package one home no matter which
directory the command runs from. ``docsgpt up`` keeps its stack in the same
place.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
HOME_ENV = "DOCSGPT_HOME"
@@ -31,12 +34,19 @@ def checkout_root() -> Path | None:
return root if (root / "pyproject.toml").is_file() else None
def default_home() -> Path:
"""The home outside a checkout: ``/opt/docsgpt`` for root on Linux, else ``~/.docsgpt/server``."""
if sys.platform.startswith("linux") and hasattr(os, "geteuid") and os.geteuid() == 0:
return Path("/opt/docsgpt")
return Path.home() / ".docsgpt" / "server"
def home_dir() -> Path:
"""Directory for runtime data: ``DOCSGPT_HOME``, else the checkout, else cwd."""
"""Directory for runtime data: ``DOCSGPT_HOME``, else the checkout, else the default home."""
configured = os.environ.get(HOME_ENV)
if configured:
return Path(configured).expanduser().resolve()
return checkout_root() or Path.cwd()
return checkout_root() or default_home()
def env_file() -> Path:
-596
View File
@@ -1,596 +0,0 @@
import os
from typing import Optional
from pydantic import AliasChoices, Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from docsgpt.core.db_uri import (
normalize_pgvector_connection_string,
normalize_postgres_uri,
)
from docsgpt.core.paths import env_file, home_dir
# Runtime data home (DOCSGPT_HOME, the checkout, or cwd); see docsgpt.core.paths.
current_dir = str(home_dir())
class Settings(BaseSettings):
model_config = SettingsConfigDict(extra="ignore")
AUTH_TYPE: Optional[str] = None # simple_jwt, session_jwt, oidc, or None
# OIDC SSO (AUTH_TYPE=oidc) — any OpenID Connect IdP with discovery (Authentik, Keycloak, ...)
OIDC_ISSUER: Optional[str] = None # e.g. https://auth.example.com/application/o/docsgpt/
OIDC_CLIENT_ID: Optional[str] = None
OIDC_CLIENT_SECRET: Optional[str] = None # optional; PKCE is always used
OIDC_SCOPES: str = "openid profile email"
OIDC_USER_ID_CLAIM: str = "sub" # ID-token claim mapped to the DocsGPT user id
OIDC_FRONTEND_URL: Optional[str] = None # browser-facing app origin, e.g. http://localhost:5173
OIDC_REDIRECT_URI: Optional[str] = None # override; default <request host>/api/auth/oidc/callback
OIDC_SESSION_LIFETIME_SECONDS: int = 28800 # minted session JWT lifetime (8h)
OIDC_PROVIDER_NAME: Optional[str] = None # sign-in button label, e.g. "Acme SSO"
OIDC_ALLOWED_GROUPS: Optional[str] = None # comma-separated allowlist; unset = any authenticated user
OIDC_GROUPS_CLAIM: str = "groups" # ID-token/userinfo claim carrying group membership
OIDC_ADMIN_GROUPS: Optional[str] = None # comma-separated groups granted admin; unset = no OIDC admin mapping
# RBAC: persisted admin grants live in user_roles (AUTH_TYPE=oidc only). This is the
# only non-DB admin path, for AUTH_TYPE=None self-host. MUST stay False if networked.
LOCAL_MODE_ADMIN: bool = False
# SCIM 2.0 provisioning (IdP-driven user create/deactivate at /scim/v2)
SCIM_ENABLED: bool = False
SCIM_TOKEN: Optional[str] = None # bearer token for IdP SCIM clients (required when enabled)
LLM_PROVIDER: str = "docsgpt"
LLM_NAME: Optional[str] = None # if LLM_PROVIDER is openai, LLM_NAME can be gpt-4 or gpt-3.5-turbo
# Legacy model on purpose: an install that never pinned this has vectors from it, and
# granite is the same width so a swap would fail silently. New installs get granite from
# .env-template; existing ones switch by setting this and running docsgpt.scripts.reembed.
EMBEDDINGS_NAME: str = "huggingface_sentence-transformers/all-mpnet-base-v2"
EMBEDDINGS_BASE_URL: Optional[str] = None # Remote embeddings API URL (OpenAI-compatible)
EMBEDDINGS_KEY: Optional[str] = None # api key for embeddings (if using openai, just copy API_KEY)
EMBEDDINGS_MAX_INPUT_TOKENS: Optional[int] = None # truncate each remote embed input to N tokens (overflow lost)
EMBEDDINGS_BATCH_SIZE: int = 32 # chunks per store transaction / remote embed request
# Documents per local ONNX forward pass. Each pass pads to its longest input, and that
# waste grows with the square of chunk length: at 1250 tokens, 32 peaked at 6.6 GB, 1 at 2.9 GB.
EMBEDDINGS_MODEL_BATCH_SIZE: int = 1
# Intra-op threads for the local ONNX runner; None = every core. It scales sub-linearly,
# so several single-threaded workers beat one many-threaded process on the same cores.
EMBEDDINGS_THREADS: Optional[int] = None
# Embedding models and their tokenizers. Persistent by default: FastEmbed's own default is the temp dir.
EMBEDDINGS_CACHE_DIR: Optional[str] = Field(default_factory=lambda: str(home_dir() / "models"))
# Pooling ("cls"/"mean") and L2 normalisation. Read from the model's own repository;
# set these only for a repository that declares neither, or to override what it declares.
EMBEDDINGS_POOLING: Optional[str] = None
EMBEDDINGS_NORMALIZE: Optional[bool] = None
# Embed on the worker so the API holds no model (~890 MB), at one broker round trip per
# query. Ignored when EMBEDDINGS_BASE_URL is set, which is the better answer for production.
EMBEDDINGS_DELEGATE_TO_WORKER: bool = True
EMBEDDINGS_QUEUE: str = "embeddings" # queue the embed task is routed to
EMBEDDINGS_DELEGATE_TIMEOUT: int = 60 # seconds to wait for the worker
GITHUB_INGEST_MAX_FILE_BYTES: int = 1048576 # skip repo blobs larger than this (0 = no cap)
GITHUB_INGEST_MAX_WORKERS: int = 8 # parallel file fetches per GitHub repo ingest
# Operator-supplied model YAMLs, loaded after the built-in catalog; later wins on
# duplicate model id. See docsgpt/core/models/README.md.
MODELS_CONFIG_DIR: Optional[str] = None
CELERY_BROKER_URL: str = "redis://localhost:6379/0"
CELERY_RESULT_BACKEND: str = "redis://localhost:6379/1"
# Prefetch=1 caps SIGKILL loss to one task. Visibility timeout must exceed the longest
# legitimate task runtime but stay short enough that SIGKILLed tasks redeliver promptly.
CELERY_WORKER_PREFETCH_MULTIPLIER: int = 1
CELERY_VISIBILITY_TIMEOUT: int = 3600
# Recycle a prefork child past this resident size in KB; backstops docling/torch heap growth.
# Checked between tasks, so it does not bound the peak within one. 0 disables.
CELERY_WORKER_MAX_MEMORY_PER_CHILD: int = 4194304
CELERY_WORKER_MAX_TASKS_PER_CHILD: int = 0 # recycle after N tasks; 0 disables
# Only consulted when VECTOR_STORE=mongodb or when running scripts/db/backfill.py; user data lives in Postgres.
MONGO_URI: Optional[str] = None
# User-data Postgres DB.
POSTGRES_URI: Optional[str] = None
# On startup, apply pending Alembic migrations. Disable if you manage schema out-of-band.
AUTO_MIGRATE: bool = True
# On startup, create the target Postgres database if missing (needs CREATEDB privilege).
AUTO_CREATE_DB: bool = True
# On startup, create the pgvector/graph tables and verify the embedding dimension. No Alembic
# migration covers the vector DB (it may be a separate cluster); set False to manage it yourself.
AUTO_VECTOR_SCHEMA: bool = True
LLM_PATH: str = os.path.join(current_dir, "models/docsgpt-7b-f16.gguf")
DEFAULT_MAX_HISTORY: int = 150
DEFAULT_LLM_TOKEN_LIMIT: int = 128000 # Fallback when model not found in registry
RESERVED_TOKENS: dict = {
"system_prompt": 500,
"current_query": 500,
"safety_buffer": 1000,
}
DEFAULT_AGENT_LIMITS: dict = {
"token_limit": 50000,
"request_limit": 500,
}
UPLOAD_FOLDER: str = "inputs"
# Serve the web UI shipped in the package (docsgpt/static) from the API process.
SERVE_UI: bool = True
# Request cap is applied by Flask before multipart parsing; the per-file cap also while copying.
UPLOAD_MAX_REQUEST_BYTES: int = Field(default=256 * 1024 * 1024, gt=0)
UPLOAD_MAX_FILE_BYTES: int = Field(default=100 * 1024 * 1024, gt=0)
PARSE_SPEC_MAX_BYTES: int = Field(default=10 * 1024 * 1024, gt=0)
# ZIP limits apply cumulatively across nested archives in one extraction.
UPLOAD_MAX_ARCHIVE_BYTES: int = Field(default=250 * 1024 * 1024, gt=0)
UPLOAD_MAX_ARCHIVE_FILES: int = Field(default=10_000, gt=0)
UPLOAD_MAX_ARCHIVE_RATIO: int = Field(default=1000, gt=0)
UPLOAD_MAX_ARCHIVE_DEPTH: int = Field(default=3, ge=0)
PARSE_PDF_AS_IMAGE: bool = False
PARSE_IMAGE_REMOTE: bool = False
# Document parser for source ingestion, chat attachments and the
# read_document tool. "anydoc" (default): firecrawl-anydoc, a Rust
# converter with no ML models — milliseconds per file, ~100 MB peak RSS.
# "docling": the layout/table-model pipeline (optional install; needed
# for read_document's structured output and the docling OCR backend).
# Files anydoc cannot convert (scanned PDFs, malformed input) fall back to
# docling when it is installed, otherwise to the native OCR parsers (OCR
# on) or the legacy parsers. Rollback to the previous behaviour is this
# one variable.
DOC_PARSER_ENGINE: str = "anydoc"
# OCR for scanned PDFs and images. OCR_ENABLED covers source ingestion,
# OCR_ATTACHMENTS_ENABLED chat attachments. Which stack performs it is
# OCR_BACKEND; which engine, OCR_ENGINE. The DOCLING_OCR_* names are the
# pre-2026-09 spellings and stay accepted as aliases.
OCR_ENABLED: bool = Field(
default=False, validation_alias=AliasChoices("OCR_ENABLED", "DOCLING_OCR_ENABLED")
)
OCR_ATTACHMENTS_ENABLED: bool = Field(
default=False,
validation_alias=AliasChoices("OCR_ATTACHMENTS_ENABLED", "DOCLING_OCR_ATTACHMENTS_ENABLED"),
)
# Which stack runs OCR when it is on:
# auto — docling when installed, otherwise native.
# docling — the layout-model pipeline (hybrid region OCR, reading order,
# table structure); needs the optional docling extra.
# native — pypdfium2/Pillow page rendering straight into tesseract or a
# DeepSeek-OCR endpoint (docsgpt/parser/file/ocr_parser.py).
# No ML models in the worker; tables come out as text lines
# under tesseract.
OCR_BACKEND: str = "auto"
# Pages docling's threaded pipeline buffers in flight; the library
# default (100) drives worker RSS to ~3 GB on a mid-size PDF.
DOCLING_PIPELINE_QUEUE_MAX_SIZE: int = 2
DOCLING_COMPILE_TORCH_MODELS: bool = False
DOCLING_TABULAR_MAX_BYTES: int = 2_000_000
DOCLING_MARKUP_MAX_BYTES: int = 8_000_000
# HTML/XHTML larger than this (bytes) are head-truncated before the
# markdownify parser runs (the anydoc engine's HTML path). The tree that
# path builds costs ~50x the input — 30 MB of HTML measured at 1.6 GB RSS —
# and the upload cap is 100 MB, so the gate is what keeps one upload from
# taking the ingest worker down. 0 disables it.
MARKUP_MAX_BYTES: int = 8_000_000
# Trust-check anydoc's PDF output (docsgpt/parser/file/pdf_trust.py):
# flag composite (Type0) fonts without a ToUnicode map, and CJK-declaring
# PDFs whose extracted text has almost no CJK — the two classes where
# anydoc drops text silently. A flagged file re-parses on the docling
# fallback when docling is installed; otherwise the anydoc output is kept
# and the document gets extra_info["parse_warnings"]. ~30 ms per scanned MB.
PDF_TRUST_CHECK: bool = True
# Rewrite dot-leader / whitespace-aligned table runs in anydoc's PDF
# markdown into GFM tables (docsgpt/parser/file/tableize.py). Off by
# default: it rewrites content on a heuristic (>=3 uniform label+numbers
# lines) validated only on a small corpus so far.
ANYDOC_TABLEIZE: bool = False
# OCR engine used when OCR is on (OCR_ENABLED / OCR_ATTACHMENTS_ENABLED).
# Benched 2026-08 on EN/ZH/table/degraded scans (docs/Guides/ocr has the
# menu):
# tesseract — recommended: best classic-engine accuracy (perfect EN word
# recall, 0.000 bilingual CER, 100% table cells), ~35 MB, CPU-only.
# Needs the system binary + language packs: an optional install like
# every OCR dependency (build with INSTALL_TESSERACT=true, or apt/brew
# install tesseract-ocr for a local run). Both backends.
# deepseek — DeepSeek-OCR against an Ollama/vLLM endpoint
# (OCR_DEEPSEEK_*). Best table/CJK quality; the worker stays light
# (no layout models) but each page costs seconds on the model server.
# Both backends.
# auto — docling's pick: ocrmac on macOS (excellent), rapidocr on Linux
# (silently shreds some long text lines — avoid as a server default).
# ocrmac | rapidocr — force one of those.
# auto/ocrmac/rapidocr exist only inside docling; the native backend runs
# tesseract for them. An engine that is not installed degrades (docling:
# to "auto") with a warning instead of failing the parse.
OCR_ENGINE: str = "tesseract"
# Tesseract language packs, "+"-separated (e.g. "eng+chi_sim+deu"). Other
# engines keep their own defaults — their language codes differ.
OCR_LANGS: str = "eng"
OCR_DEEPSEEK_URL: str = "http://localhost:11434/v1/chat/completions"
OCR_DEEPSEEK_MODEL: str = "deepseek-ocr:3b"
# Seconds allowed per page request to the DeepSeek endpoint, on both
# backends (native sends pages one at a time; docling's VLM pipeline
# keeps its own concurrency). A 3B model on a laptop needs minutes; a
# vLLM GPU deployment, seconds.
OCR_DEEPSEEK_TIMEOUT: float = 300.0
# Native backend only: resolution at which pages without a text layer are
# rendered before OCR. 200 suits tesseract; clamped to 72-600.
OCR_RENDER_DPI: int = 200
# Chars-per-page floor below which an OCR'd PDF/image parse is treated as an OCR
# dropout (long-running docling workers were observed returning zero characters for
# every scanned page after a long scanned PDF, with no error) rather than as content.
# docling retries once on a fresh full-page-OCR converter; both backends then fail
# loudly instead of indexing an empty document. 0 disables the guard.
OCR_MIN_CHARS_PER_PAGE: int = Field(
default=20, validation_alias=AliasChoices("OCR_MIN_CHARS_PER_PAGE", "DOCLING_OCR_MIN_CHARS_PER_PAGE")
)
# Read PDF *attachments* via their embedded text layer (pypdfium2) instead
# of docling, falling back to docling when there is no text layer to read.
# Attachments go into a prompt, so docling's structural markdown earns far
# less than the tens of seconds per file it costs; source ingestion is
# unaffected and always uses docling, because chunking and retrieval do
# depend on that structure.
ATTACHMENT_PDF_TEXT_FAST_PATH: bool = True
# Median chars per sampled page below which a PDF is treated as a scan and handed to docling.
# Measured on real uploads: scans at 0-17 chars/page, text-layer documents at 433-6834.
ATTACHMENT_PDF_TEXT_MIN_MEDIAN_CHARS: int = 32
ATTACHMENT_TEXT_MAX_BYTES: int = 5_000_000
AGENT_IMAGE_MAX_BYTES: int = 5_000_000
AGENT_IMAGE_MAX_PIXELS: int = 16_777_216
VECTOR_STORE: str = "faiss" # "faiss" or "elasticsearch" or "qdrant" or "milvus" or "lancedb" or "pgvector"
# Retriever keys an agent may use; must match RetrieverCreator.retrievers registry keys,
# NOT the legacy ``classic_rag`` label which never matched the registry.
RETRIEVERS_ENABLED: list = ["classic", "default"]
# Concurrent per-source searches in one retrieval; the query is embedded once and shared.
RETRIEVAL_MAX_PARALLEL_SOURCES: int = 4
# Kill-switch for per-source retrieval dispatch; False collapses to a single retriever.
PER_SOURCE_RETRIEVAL_ENABLED: bool = True
GRAPHRAG_ENABLED: bool = False # gates graph-aware ingestion/retrieval
# Model for ingest-time graph extraction; None reuses LLM_PROVIDER/LLM_NAME.
GRAPHRAG_EXTRACTION_MODEL: Optional[str] = None
# Hard cap on chunks extracted per source (cost control).
GRAPHRAG_MAX_CHUNKS_FOR_EXTRACTION: int = 2000
AGENT_NAME: str = "classic"
FALLBACK_LLM_PROVIDER: Optional[str] = None # provider for fallback llm
FALLBACK_LLM_NAME: Optional[str] = None # model name for fallback llm
FALLBACK_LLM_API_KEY: Optional[str] = None # api key for fallback llm
# Google Drive integration
GOOGLE_CLIENT_ID: Optional[str] = None # Replace with your actual Google OAuth client ID
GOOGLE_CLIENT_SECRET: Optional[str] = None # Replace with your actual Google OAuth client secret
CONNECTOR_REDIRECT_BASE_URI: Optional[str] = (
"http://127.0.0.1:7091/api/connectors/callback" ##add redirect url as it is to your provider's console(gcp)
)
# Comma-separated frontend origins allowed to receive connector OAuth results, e.g. https://docsgpt.example.com.
# The callback origin and OIDC_FRONTEND_URL are always allowed; a loopback callback also allows localhost:5173.
CONNECTOR_ALLOWED_ORIGINS: Optional[str] = None
# Microsoft Entra ID (Azure AD) integration
MICROSOFT_CLIENT_ID: Optional[str] = None # Azure AD Application (client) ID
MICROSOFT_CLIENT_SECRET: Optional[str] = None # Azure AD Application client secret
MICROSOFT_TENANT_ID: Optional[str] = "common" # Azure AD Tenant ID (or 'common' for multi-tenant)
MICROSOFT_AUTHORITY: Optional[str] = None # e.g., "https://login.microsoftonline.com/{tenant_id}"
# Confluence Cloud integration
CONFLUENCE_CLIENT_ID: Optional[str] = None
CONFLUENCE_CLIENT_SECRET: Optional[str] = None
# GitHub source
GITHUB_ACCESS_TOKEN: Optional[str] = None # PAT token with read repo access
# LLM Cache
CACHE_REDIS_URL: str = "redis://localhost:6379/2"
API_URL: str = "http://localhost:7091" # backend url for celery worker
# Public base URL for user-facing endpoint references in prompts
PUBLIC_API_BASE_URL: Optional[str] = None
MCP_OAUTH_REDIRECT_URI: Optional[str] = None # public callback URL for MCP OAuth
INTERNAL_KEY: Optional[str] = None # internal api key for worker-to-backend auth
API_KEY: Optional[str] = None # LLM api key (used by LLM_PROVIDER)
# Provider-specific API keys (for multi-model support)
OPENAI_API_KEY: Optional[str] = None
ANTHROPIC_API_KEY: Optional[str] = None
GOOGLE_API_KEY: Optional[str] = None
GROQ_API_KEY: Optional[str] = None
HUGGINGFACE_API_KEY: Optional[str] = None
OPEN_ROUTER_API_KEY: Optional[str] = None
NOVITA_API_KEY: Optional[str] = None
OPENAI_API_BASE: Optional[str] = None # azure openai api base url
OPENAI_API_VERSION: Optional[str] = None # azure openai api version
AZURE_DEPLOYMENT_NAME: Optional[str] = None # azure deployment name for answering
AZURE_EMBEDDINGS_DEPLOYMENT_NAME: Optional[str] = None # azure deployment name for embeddings
OPENAI_BASE_URL: Optional[str] = None # openai base url for open ai compatable models
# elasticsearch
ELASTIC_CLOUD_ID: Optional[str] = None # cloud id for elasticsearch
ELASTIC_USERNAME: Optional[str] = None # username for elasticsearch
ELASTIC_PASSWORD: Optional[str] = None # password for elasticsearch
ELASTIC_URL: Optional[str] = None # url for elasticsearch
ELASTIC_INDEX: Optional[str] = "docsgpt" # index name for elasticsearch
# Legacy AWS credentials from the retired SageMaker provider. Still read as a deprecated
# fallback by S3 storage; do not use for new deployments.
SAGEMAKER_REGION: Optional[str] = None
SAGEMAKER_ACCESS_KEY: Optional[str] = None
SAGEMAKER_SECRET_KEY: Optional[str] = None
# Qdrant vectorstore config
QDRANT_COLLECTION_NAME: Optional[str] = "docsgpt"
QDRANT_LOCATION: Optional[str] = None
QDRANT_URL: Optional[str] = None
QDRANT_PORT: Optional[int] = 6333
QDRANT_GRPC_PORT: int = 6334
QDRANT_PREFER_GRPC: bool = False
QDRANT_HTTPS: Optional[bool] = None
QDRANT_API_KEY: Optional[str] = None
QDRANT_PREFIX: Optional[str] = None
QDRANT_TIMEOUT: Optional[float] = None
QDRANT_HOST: Optional[str] = None
QDRANT_PATH: Optional[str] = None
QDRANT_DISTANCE_FUNC: str = "Cosine"
# PGVector config. postgres://, postgresql:// and postgresql+psycopg:// are all accepted
# and normalized internally for psycopg.connect().
PGVECTOR_CONNECTION_STRING: Optional[str] = None
PGVECTOR_POOL_MAX_SIZE: int = 8 # per-process pool; 0 = one direct connection per store
# IVFFlat probes; None derives sqrt(lists) from the index. Higher = better recall, more scan.
PGVECTOR_IVFFLAT_PROBES: Optional[int] = None
# Milvus vectorstore config
MILVUS_COLLECTION_NAME: Optional[str] = "docsgpt"
# milvus-lite (embedded) database file, under the data home like the other local stores
MILVUS_URI: Optional[str] = Field(default_factory=lambda: str(home_dir() / "milvus_local.db"))
MILVUS_TOKEN: Optional[str] = ""
# LanceDB vectorstore config
LANCEDB_PATH: str = Field(default_factory=lambda: str(home_dir() / "data" / "lancedb")) # LanceDB local data
LANCEDB_TABLE_NAME: Optional[str] = "docsgpts" # Name of the table to use for storing vectors
FLASK_DEBUG_MODE: bool = False
STORAGE_TYPE: str = "local" # local or s3
# S3-compatible object storage (STORAGE_TYPE=s3): AWS S3, MinIO, R2, B2, Spaces, ...
# For non-AWS, set S3_ENDPOINT_URL and usually S3_PATH_STYLE=true.
S3_BUCKET_NAME: str = "docsgpt-test-bucket"
S3_ENDPOINT_URL: Optional[str] = None # custom endpoint for S3-compatible services; omit for AWS
S3_ACCESS_KEY_ID: Optional[str] = None
S3_SECRET_ACCESS_KEY: Optional[str] = None
S3_REGION: Optional[str] = None # AWS region; use "auto" for Cloudflare R2
S3_PATH_STYLE: bool = False # path-style addressing (required by most non-AWS services)
# Anonymous startup version check for security issues.
VERSION_CHECK: bool = True
URL_STRATEGY: str = "backend" # backend or s3
JWT_SECRET_KEY: str = ""
# Encryption settings
ENCRYPTION_SECRET_KEY: str = "default-docsgpt-encryption-key"
TTS_PROVIDER: str = "google_tts" # google_tts, elevenlabs, or none to switch text-to-speech off
ELEVENLABS_API_KEY: Optional[str] = None
STT_PROVIDER: str = "openai" # openai, faster_whisper, or none to switch speech-to-text off
OPENAI_STT_MODEL: str = "gpt-4o-mini-transcribe"
STT_LANGUAGE: Optional[str] = None
STT_MAX_FILE_SIZE_MB: int = 50
STT_ENABLE_TIMESTAMPS: bool = False
STT_ENABLE_DIARIZATION: bool = False
# Tool pre-fetch settings
ENABLE_TOOL_PREFETCH: bool = True
# True persists Responses API calls server-side so previous_response_id can chain turns.
# False keeps them stateless, carrying reasoning across the tool loop as encrypted items.
OPENAI_RESPONSES_STORE: bool = False
# Cross-turn ``previous_response_id`` chaining (store mode only). The
# chained transcript lives on the provider and is invisible to every
# local guard, so it is bounded: a turn starts from the local history
# when the previous turn's reported prompt already reached the budget
# (default: the model's context window) or when the conversation was
# compressed after that turn was produced.
OPENAI_RESPONSES_CHAIN_ACROSS_TURNS: bool = True
OPENAI_RESPONSES_CHAIN_BUDGET_TOKENS: Optional[int] = None
# ``truncation: "auto"`` lets the provider drop the oldest input items
# instead of failing every request once a chain exceeds the model's window.
OPENAI_RESPONSES_TRUNCATION_AUTO: bool = False
# Prompt-cache hints on the Responses API: route a user's calls to the
# same cache shard (opaque per-user key), and request extended retention
# where offered.
OPENAI_PROMPT_CACHE_KEY: bool = True
OPENAI_PROMPT_CACHE_RETENTION: Optional[str] = None
OPENAI_REASONING_SUMMARY: str = "auto"
# Lets OpenAI-compatible clients identify a logical chat by session header, which
# chat-completions itself has no field for.
V1_SESSION_TTL_SECONDS: int = 24 * 60 * 60
# Optional cheaper model for conversation titles; unset reuses the answer model.
TITLE_MODEL_ID: Optional[str] = None
# Config-free tools on by default in agentless chats. ``scheduler`` is dual-registered in
# BUILTIN_AGENT_TOOLS so one synthetic id resolves via defaults or the agent picker.
# Add "code_executor" and "artifact_generator" once a sandbox runner is configured — both
# execute through it and would fail on every call without one.
DEFAULT_CHAT_TOOLS: list = [
"memory",
"read_webpage",
"scheduler",
]
# Conversation Compression Settings
ENABLE_CONVERSATION_COMPRESSION: bool = True
COMPRESSION_THRESHOLD_PERCENTAGE: float = 0.8 # Trigger at 80% of context
COMPRESSION_MODEL_OVERRIDE: Optional[str] = None # Use different model for compression
COMPRESSION_PROMPT_VERSION: str = "v1.0" # Track prompt iterations
COMPRESSION_MAX_HISTORY_POINTS: int = 3 # Keep only last N compression points to prevent DB bloat
# Per-field cap on the verbatim tail kept after a compression point (0 disables).
COMPRESSION_RECENT_FIELD_MAX_TOKENS: int = 8000
# Cap on one tool result entering the LLM context (0 disables); journal/DB keep it whole.
TOOL_RESULT_MAX_TOKENS: int = 20000
# Agent Guardrails
GUARDRAILS_ENABLED: bool = True # master switch; False disables every stage
# Allowlist of GuardrailCreator.checks keys; empty means every registered check.
GUARDRAILS_CHECKS_ENABLED: list = []
# A GuardrailsConfig fragment every agent inherits and cannot weaken; agents may add
# controls or make an action stricter, never looser. "enabled" is required — without it
# the floor parses but applies to nothing. Example:
# {"enabled": true, "mode": "scan_all",
# "controls": [{"check": "secrets", "stage": "output", "action": "redact"}]}
GUARDRAILS_FLOOR: dict = {}
# Judge model for the topic/policy checks; None reuses the request's model.
GUARDRAILS_JUDGE_MODEL: Optional[str] = None
# Persist scanned text alongside guardrail_events. Off by default: pre-redaction text is
# exactly the material a PII control exists to keep out of storage.
GUARDRAILS_STORE_SCANNED_TEXT: bool = False
GUARDRAILS_EVENTS_RETENTION_DAYS: int = Field(default=30, ge=1)
# Internal SSE push channel (notifications + durable replay journal).
# False makes /api/events emit "push_disabled" and return; clients fall back to polling.
ENABLE_SSE_PUSH: bool = True
# Per-user durable backlog cap in entries; ~24h of replay at typical rates.
EVENTS_STREAM_MAXLEN: int = 1000
# Bounds uvicorn's shutdown drain (uvicorn_worker doesn't forward --graceful-timeout).
# Keep below the gunicorn --timeout (180) watchdog. Used by BoundedDrainUvicornWorker.
GRACEFUL_SHUTDOWN_TIMEOUT_SECONDS: int = 30
WSGI_THREADPOOL_WORKERS: int = 96
SSE_KEEPALIVE_SECONDS: int = Field(default=15, ge=1)
# Simultaneous SSE connections per user; each holds a pooled async Redis connection for
# its lifetime. 8 covers multi-tab use without one user starving the pool. 0 disables.
SSE_MAX_CONCURRENT_PER_USER: int = 8
# Pool size of the async Redis client behind the event-loop routes, per process. Every
# open notification tab, chat reconnect and device session holds one connection, so this
# caps concurrent streams per worker (redis-py's own default is 100). Keep the total
# across workers below the Redis server's maxclients (10000 by default).
ASYNC_REDIS_MAX_CONNECTIONS: int = Field(default=2000, ge=1)
# Backlog entries XRANGE returns per /api/events snapshot. Bounds what one replay moves
# from Redis to the wire: a client looping Last-Event-ID reconnects enumerates at most
# this many per round-trip, and the budget below bounds total throughput.
EVENTS_REPLAY_MAX_PER_REQUEST: int = 200
EVENTS_REPLAY_MAX_AGE_HOURS: int = 48
# Sliding-window cap on snapshot replays per user; exhausting it returns 429 with the
# cursor pinned so the client backs off until the window rolls over.
EVENTS_REPLAY_BUDGET_REQUESTS_PER_WINDOW: int = 30
EVENTS_REPLAY_BUDGET_WINDOW_SECONDS: int = 60
# Retention for the message_events journal, enforced by the cleanup_message_events beat
# task. Replay only needs streams a client could still be tailing.
MESSAGE_EVENTS_RETENTION_DAYS: int = 14
# Remote Device feature.
REMOTE_DEVICE_SESSION_IDLE_SECONDS: int = 60
REMOTE_DEVICE_REQUIRE_SIGNATURE: bool = False
REMOTE_DEVICE_PAIRING_TTL_SECONDS: int = 600
# Redis broker tunables, routing invocations cross-process so a scheduled run reaches the
# web-held device session. The queue TTL must exceed the max drain deadline (605s) so a
# command for a briefly-offline device isn't evicted before its own drain gives up.
REMOTE_DEVICE_CMD_QUEUE_TTL_SECONDS: int = 900
REMOTE_DEVICE_INVOCATION_TTL_SECONDS: int = 900
REMOTE_DEVICE_OUTPUT_STREAM_MAXLEN: int = 10_000
# Scheduler (see scheduler.md).
SCHEDULE_DISPATCHER_INTERVAL: int = 30
SCHEDULE_MIN_INTERVAL: int = 900
SCHEDULE_MAX_PER_USER: int = 50
SCHEDULE_RUN_TIMEOUT: int = 600
SCHEDULE_MISFIRE_GRACE: int = 60
SCHEDULE_AUTOPAUSE_FAILURES: int = 3
SCHEDULE_ONCE_MAX_HORIZON: int = 31_536_000
SCHEDULE_RUN_OUTPUT_RETENTION_DAYS: int = 90
# Code-execution sandbox. The app is a CLIENT of an always-on runner; defaults are safe so
# app import never fails when the sandbox is unconfigured.
SANDBOX_BACKEND: str = "jupyter" # "jupyter" (self-host) | "daytona" (Daytona Cloud)
# URL of the Jupyter Kernel Gateway runner (the docsgpt-sandbox service).
SANDBOX_GATEWAY_URL: str = "http://localhost:8888"
SANDBOX_GATEWAY_AUTH_TOKEN: Optional[str] = None # gateway auth token, if set
# Kernelspec per session. The env-scrubbing "docsgpt-python" spec keeps kernel code from
# reading the gateway token or operator secrets from os.environ; the stock "python3" spec
# inherits the gateway env verbatim and must not be used with untrusted code.
SANDBOX_KERNEL_NAME: str = "docsgpt-python"
SANDBOX_MAX_TTL: int = 1200 # hard cap (s) on agent-selectable keep-alive TTL
# Concurrent live sessions per process, backend-agnostic; at the cap an LRU-idle session is
# evicted. 0 or negative disables the cap.
SANDBOX_MAX_SESSIONS: int = 32
SANDBOX_EXEC_TIMEOUT: int = 60 # default wall-clock cap (s) per exec call
SANDBOX_HTTP_TIMEOUT: int = 10 # fixed cap (s) for REST control calls (create/delete/alive/interrupt)
SANDBOX_MAX_OUTPUT_BYTES: int = 8 * 1024 * 1024 # cap on buffered stdout+stderr per exec
SANDBOX_MAX_FILE_BYTES: int = 10 * 1024 * 1024 # cap on get_file size routed through stdout
SANDBOX_MAX_INPUT_BYTES: int = 25 * 1024 * 1024 # cap on an input document staged into a sandbox session
# ``read_document`` parsing on a dedicated Celery ``parsing`` queue (backend parser).
DOCUMENT_PARSE_QUEUE: str = "parsing" # queue the parse_document task is routed to
DOCUMENT_PARSE_TIMEOUT: int = 120 # seconds the tool awaits the enqueued parse before degrading
# The base timeout is a FLOOR: the window grows with document size, because OCR cost scales
# with pages. Without this a large scan is silently dropped at the base window.
DOCUMENT_PARSE_TIMEOUT_PER_MB: int = 60 # extra seconds of parse window per MiB of input
DOCUMENT_PARSE_TIMEOUT_MAX: int = 900 # absolute ceiling on the size-scaled parse window
DOCUMENT_PARSE_MAX_BYTES: int = 0 # cap on a parsed document's bytes (0 = reuse SANDBOX_MAX_INPUT_BYTES)
DOCUMENT_MAX_DECOMPRESSED_BYTES: int = 300 * 1024 * 1024
DOCUMENT_MAX_ARCHIVE_ENTRIES: int = 10000
# Files per node passed natively to the LLM; past the cap they are extracted to text or
# dropped, to bound context and cost. Re-uses SANDBOX_MAX_INPUT_BYTES per file.
WORKFLOW_NODE_NATIVE_MAX_FILES: int = 5
# Documents per node extracted via the parsing worker. Each issues a separate blocking
# parse; past the cap they are skipped with a truncation note.
WORKFLOW_NODE_EXTRACT_MAX_FILES: int = 5
# Wall clock one node may spend on blocking parses, shared across all of them. Without it a
# node could serialize WORKFLOW_NODE_EXTRACT_MAX_FILES full windows on a web threadpool slot.
WORKFLOW_NODE_EXTRACT_BUDGET_SECONDS: int = 900
# A run row is pre-created as ``running``; a disconnect or crash can strand it there. The
# beat reaper fails runs still ``running`` past this. Generous so a long run is never cut off.
WORKFLOW_RUN_STALE_SECONDS: int = 3600
# Runner container caps, consumed by the docsgpt-sandbox compose service, not the app.
# These cgroup limits are part of the untrusted-code security boundary.
SANDBOX_MEMORY: str = "1g" # docker mem_limit for the runner container
SANDBOX_CPUS: str = "1.0" # docker cpu quota for the runner container
# Daytona Cloud backend (SANDBOX_BACKEND="daytona"). All knobs are optional so app import
# never fails when the backend is unused.
DAYTONA_API_KEY: Optional[str] = None # Daytona Cloud API key (secret)
DAYTONA_API_URL: Optional[str] = None # override Daytona API base URL, if self-targeting
DAYTONA_TARGET: Optional[str] = None # Daytona region/target, e.g. "us"
DAYTONA_SNAPSHOT: Optional[str] = None # image for new sandboxes; render libs via scripts/build_daytona_snapshot.py
DAYTONA_LANGUAGE: str = "python" # default runtime language for created sandboxes
DAYTONA_AUTO_STOP_INTERVAL: int = 15 # minutes idle before Daytona auto-stops a sandbox (0 disables)
DAYTONA_AUTO_DELETE_INTERVAL: int = 60 # minutes after stop before Daytona auto-deletes (-1 disables)
DAYTONA_MAX_SANDBOXES: int = 50 # cap on concurrent live Daytona sandboxes (cost-DoS guard)
# Per-user artifact quotas, enforced at persistence time. 0 or negative disables a quota.
ARTIFACT_MAX_BYTES: int = 50 * 1024 * 1024 # cap on a single stored artifact version's bytes
ARTIFACT_MAX_COUNT_PER_USER: int = 5000 # cap on artifacts a user may own
ARTIFACT_MAX_TOTAL_BYTES_PER_USER: int = 5 * 1024 * 1024 * 1024 # cap on a user's total stored bytes
@field_validator("POSTGRES_URI", mode="before")
@classmethod
def _normalize_postgres_uri_validator(cls, v):
return normalize_postgres_uri(v)
@field_validator("PGVECTOR_CONNECTION_STRING", mode="before")
@classmethod
def _normalize_pgvector_connection_string_validator(cls, v):
return normalize_pgvector_connection_string(v)
@field_validator(
"API_KEY",
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"GOOGLE_API_KEY",
"GROQ_API_KEY",
"HUGGINGFACE_API_KEY",
"NOVITA_API_KEY",
"EMBEDDINGS_KEY",
"FALLBACK_LLM_API_KEY",
"QDRANT_API_KEY",
"ELEVENLABS_API_KEY",
"INTERNAL_KEY",
mode="before",
)
@classmethod
def normalize_api_key(cls, v: Optional[str]) -> Optional[str]:
"""
Normalize API keys: convert 'None', 'none', empty strings,
and whitespace-only strings to actual None.
Handles Pydantic loading 'None' from .env as string "None".
"""
if v is None:
return None
if not isinstance(v, str):
return v
stripped = v.strip()
if stripped == "" or stripped.lower() == "none":
return None
return stripped
settings = Settings(_env_file=env_file(), _env_file_encoding="utf-8")
+79
View File
@@ -0,0 +1,79 @@
"""Application settings.
``settings`` is the process-wide instance, loaded from the environment and the
``.env`` file in the data home (see ``docsgpt.core.paths``). Every setting is a
flat attribute, ``settings.NAME``, matching the environment variable of the
same name.
The definitions are split by domain into the modules of this package; each
module owns one ``SettingsGroup`` and ``Settings`` composes them all. Add a new
setting to the group it belongs to (or add a group and list it in
``SETTINGS_GROUPS``), with a ``description`` -- the settings reference in the
docs is generated from these definitions.
"""
from __future__ import annotations
from typing import Optional
from docsgpt.core.paths import env_file, home_dir
from docsgpt.core.settings._shared import SettingsGroup, normalize_secret
from docsgpt.core.settings.agents import AgentSettings
from docsgpt.core.settings.auth import AuthSettings
from docsgpt.core.settings.connectors import ConnectorSettings
from docsgpt.core.settings.database import DatabaseSettings
from docsgpt.core.settings.embeddings import EmbeddingsSettings
from docsgpt.core.settings.events import EventsSettings
from docsgpt.core.settings.guardrails import GuardrailSettings
from docsgpt.core.settings.ingestion import IngestionSettings
from docsgpt.core.settings.llm import LLMSettings
from docsgpt.core.settings.ocr import OCRSettings
from docsgpt.core.settings.quotas import QuotaSettings
from docsgpt.core.settings.retrieval import RetrievalSettings
from docsgpt.core.settings.sandbox import SandboxSettings
from docsgpt.core.settings.scheduler import SchedulerSettings
from docsgpt.core.settings.server import ServerSettings
from docsgpt.core.settings.speech import SpeechSettings
from docsgpt.core.settings.storage import StorageSettings
from docsgpt.core.settings.vectorstores import VectorStoreSettings
from docsgpt.core.settings.workers import WorkerSettings
#: Every settings group, in the order the generated reference lists them.
SETTINGS_GROUPS: tuple[tuple[str, type[SettingsGroup]], ...] = (
("Authentication", AuthSettings),
("LLM providers", LLMSettings),
("Embeddings", EmbeddingsSettings),
("Retrieval", RetrievalSettings),
("Vector stores", VectorStoreSettings),
("User-data database", DatabaseSettings),
("Workers", WorkerSettings),
("Ingestion and parsing", IngestionSettings),
("OCR", OCRSettings),
("File storage", StorageSettings),
("Connectors", ConnectorSettings),
("Server", ServerSettings),
("Events and devices", EventsSettings),
("Agents", AgentSettings),
("Guardrails", GuardrailSettings),
("Quotas", QuotaSettings),
("Scheduler", SchedulerSettings),
("Sandbox", SandboxSettings),
("Speech", SpeechSettings),
)
# Runtime data home (DOCSGPT_HOME, the checkout, or cwd); see docsgpt.core.paths.
current_dir = str(home_dir())
class Settings(*(group for _, group in SETTINGS_GROUPS)):
"""All settings, composed from the per-domain groups in this package."""
@classmethod
def normalize_api_key(cls, v: Optional[str]) -> Optional[str]:
"""Normalize a secret the way the per-field validators do; kept for callers that reuse it."""
return normalize_secret(v)
settings = Settings(_env_file=env_file(), _env_file_encoding="utf-8")
__all__ = ["SETTINGS_GROUPS", "Settings", "SettingsGroup", "current_dir", "settings"]
+80
View File
@@ -0,0 +1,80 @@
"""Building blocks shared by the settings groups.
Every group in this package is a :class:`SettingsGroup`: a ``BaseSettings``
subclass that owns one domain's fields. ``docsgpt.core.settings.Settings``
inherits from all of them, so the composed class keeps the flat
``settings.NAME`` attributes the rest of the codebase reads while each
domain's definitions live in their own module.
"""
from __future__ import annotations
import types
import typing
from typing import Any, Optional
from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
def _is_optional_str(annotation: Any) -> bool:
"""``Optional[str]`` or ``Optional[Literal[...]]`` whose choices are all strings."""
if typing.get_origin(annotation) not in (typing.Union, types.UnionType):
return False
members = set(typing.get_args(annotation))
if type(None) not in members or len(members) != 2:
return False
(member,) = members - {type(None)}
if member is str:
return True
return typing.get_origin(member) is typing.Literal and all(
isinstance(choice, str) for choice in typing.get_args(member)
)
class SettingsGroup(BaseSettings):
"""Base for one domain's settings; groups are composed into ``Settings``.
Every ``Optional[str]`` field (and optional string ``Literal``) treats the spellings an unset value has in a
``.env`` file (``KEY=``, ``KEY=None``, whitespace) as ``None``, so a check
like ``if settings.OIDC_ISSUER`` or a fallback like ``settings.X or default``
sees "unset" rather than a truthy placeholder string. Real values are
stripped. Fields typed ``str`` keep whatever they are given.
"""
model_config = SettingsConfigDict(extra="ignore")
@model_validator(mode="before")
@classmethod
def _unset_optional_strings(cls, data: Any) -> Any:
if not isinstance(data, dict):
return data
data = dict(data)
for name, field in cls.model_fields.items():
if name in data and _is_optional_str(field.annotation):
data[name] = normalize_secret(data[name])
return data
def normalize_choice(value: Any) -> Any:
"""Case-fold a closed-choice setting so ``PGVector`` and ``pgvector`` are the same choice."""
if isinstance(value, str):
return value.strip().lower()
return value
def normalize_secret(value: Optional[str]) -> Optional[str]:
"""Map the ways an unset secret reaches us from ``.env`` to ``None``.
``.env`` files carry ``KEY=None`` and ``KEY=`` for "not set", and pydantic
would otherwise keep those as the strings ``"None"`` and ``""``. Whitespace
around a real value is stripped.
"""
if value is None:
return None
if not isinstance(value, str):
return value
stripped = value.strip()
if stripped == "" or stripped.lower() == "none":
return None
return stripped
+91
View File
@@ -0,0 +1,91 @@
"""Agent runtime: default tools, context management, workflows and artifacts."""
from __future__ import annotations
from typing import Optional
from pydantic import Field
from docsgpt.core.settings._shared import SettingsGroup
class AgentSettings(SettingsGroup):
"""What an agent may do per turn and how its context is kept within budget."""
AGENT_NAME: str = Field(default="classic", description="Default agent type for agentless chats.")
DEFAULT_AGENT_LIMITS: dict[str, int] = Field(
default={"token_limit": 50000, "request_limit": 500},
description="Per-agent default quotas: tokens and requests.",
)
DEFAULT_CHAT_TOOLS: list[str] = Field(
default=["memory", "read_webpage", "scheduler"],
description=(
"Config-free tools on by default in agentless chats. scheduler is dual-registered in "
"BUILTIN_AGENT_TOOLS so one synthetic id resolves via defaults or the agent picker. Add "
"code_executor and artifact_generator once a sandbox runner is configured; both execute through "
"it and would fail on every call without one."
),
)
ENABLE_TOOL_PREFETCH: bool = Field(default=True, description="Pre-fetch retrieval before the agent's first turn.")
TOOL_RESULT_MAX_TOKENS: int = Field(
default=20000,
ge=0,
description="Cap on one tool result entering the LLM context (0 disables); journal and DB keep it whole.",
)
# Conversation compression.
ENABLE_CONVERSATION_COMPRESSION: bool = Field(
default=True, description="Compress long conversations once they approach the context window."
)
COMPRESSION_THRESHOLD_PERCENTAGE: float = Field(
default=0.8, gt=0, le=1, description="Fraction of the context window at which compression triggers."
)
COMPRESSION_MODEL_OVERRIDE: Optional[str] = Field(
default=None, description="Use a different model for compression; unset reuses the answer model."
)
COMPRESSION_PROMPT_VERSION: str = Field(default="v1.0", description="Tracks compression prompt iterations.")
COMPRESSION_MAX_HISTORY_POINTS: int = Field(
default=3, description="Keep only the last N compression points to prevent DB bloat."
)
COMPRESSION_RECENT_FIELD_MAX_TOKENS: int = Field(
default=8000, ge=0, description="Per-field cap on the verbatim tail kept after a compression point (0 disables)."
)
# Workflows.
WORKFLOW_NODE_NATIVE_MAX_FILES: int = Field(
default=5,
description=(
"Files per node passed natively to the LLM; past the cap they are extracted to text or dropped, to "
"bound context and cost. Re-uses SANDBOX_MAX_INPUT_BYTES per file."
),
)
WORKFLOW_NODE_EXTRACT_MAX_FILES: int = Field(
default=5,
description=(
"Documents per node extracted via the parsing worker. Each issues a separate blocking parse; past "
"the cap they are skipped with a truncation note."
),
)
WORKFLOW_NODE_EXTRACT_BUDGET_SECONDS: int = Field(
default=900,
description=(
"Wall clock one node may spend on blocking parses, shared across all of them. Without it a node "
"could serialize WORKFLOW_NODE_EXTRACT_MAX_FILES full windows on a web threadpool slot."
),
)
WORKFLOW_RUN_STALE_SECONDS: int = Field(
default=3600,
description=(
"A run row is pre-created as running; a disconnect or crash can strand it there. The beat reaper "
"fails runs still running past this. Generous so a long run is never cut off."
),
)
# Per-user artifact quotas, enforced at persistence time. 0 or negative disables a quota.
ARTIFACT_MAX_BYTES: int = Field(
default=50 * 1024 * 1024, description="Cap on a single stored artifact version's bytes (0 disables)."
)
ARTIFACT_MAX_COUNT_PER_USER: int = Field(default=5000, description="Cap on artifacts a user may own (0 disables).")
ARTIFACT_MAX_TOTAL_BYTES_PER_USER: int = Field(
default=5 * 1024 * 1024 * 1024, description="Cap on a user's total stored artifact bytes (0 disables)."
)
+128
View File
@@ -0,0 +1,128 @@
"""Authentication, SSO and provisioning."""
from __future__ import annotations
from typing import Literal, Optional
from pydantic import Field, field_validator, model_validator
from docsgpt.core.settings._shared import SettingsGroup, normalize_choice
#: Settings an OIDC deployment cannot run without; checked when AUTH_TYPE=oidc.
OIDC_REQUIRED = ("OIDC_ISSUER", "OIDC_CLIENT_ID", "OIDC_FRONTEND_URL")
class AuthSettings(SettingsGroup):
"""How users authenticate: none, a shared token, per-session JWTs, or OIDC SSO."""
AUTH_TYPE: Optional[Literal["simple_jwt", "session_jwt", "oidc"]] = Field(
default=None,
description="Authentication mode: simple_jwt, session_jwt, oidc, or unset (None) for no authentication.",
)
JWT_SECRET_KEY: str = Field(
default="",
description=(
"Signing key for session tokens and other signed capabilities. Required on every replica in "
"production; local development may fall back to a key generated on disk."
),
)
ENCRYPTION_SECRET_KEY: str = Field(
default="default-docsgpt-encryption-key",
description="Key used to encrypt stored credentials such as tool and connector secrets.",
)
INTERNAL_KEY: Optional[str] = Field(
default=None, description="Internal API key for worker-to-backend authentication."
)
# OIDC SSO (AUTH_TYPE=oidc): any OpenID Connect IdP with discovery (Authentik, Keycloak, ...).
OIDC_ISSUER: Optional[str] = Field(
default=None,
description="OIDC issuer URL with discovery, e.g. https://auth.example.com/application/o/docsgpt/.",
)
OIDC_CLIENT_ID: Optional[str] = Field(default=None, description="OIDC client id.")
OIDC_CLIENT_SECRET: Optional[str] = Field(
default=None, description="OIDC client secret. Optional; PKCE is always used."
)
OIDC_SCOPES: str = Field(default="openid profile email", description="Scopes requested from the IdP.")
OIDC_USER_ID_CLAIM: str = Field(
default="sub", description="ID-token claim mapped to the DocsGPT user id."
)
OIDC_FRONTEND_URL: Optional[str] = Field(
default=None, description="Browser-facing app origin, e.g. http://localhost:5173."
)
OIDC_REDIRECT_URI: Optional[str] = Field(
default=None, description="Override for the callback URL; default is <request host>/api/auth/oidc/callback."
)
OIDC_SESSION_LIFETIME_SECONDS: int = Field(
default=28800, gt=0, description="Lifetime of the minted session JWT in seconds (8h)."
)
OIDC_PROVIDER_NAME: Optional[str] = Field(
default=None, description='Sign-in button label, e.g. "Acme SSO".'
)
OIDC_ALLOWED_GROUPS: Optional[str] = Field(
default=None, description="Comma-separated group allowlist; unset admits any authenticated user."
)
OIDC_GROUPS_CLAIM: str = Field(
default="groups", description="ID-token/userinfo claim carrying group membership."
)
OIDC_ADMIN_GROUPS: Optional[str] = Field(
default=None, description="Comma-separated groups granted admin; unset means no OIDC admin mapping."
)
LOCAL_MODE_ADMIN: bool = Field(
default=False,
description=(
"Grant admin without a database role. Persisted admin grants live in user_roles (AUTH_TYPE=oidc "
"only); this is the only non-DB admin path, for AUTH_TYPE=None self-host. MUST stay False if "
"networked."
),
)
# SCIM 2.0 provisioning (IdP-driven user create/deactivate at /scim/v2).
SCIM_ENABLED: bool = Field(default=False, description="Enable SCIM 2.0 provisioning at /scim/v2.")
SCIM_TOKEN: Optional[str] = Field(
default=None, description="Bearer token for IdP SCIM clients (required when SCIM is enabled)."
)
# Personal access tokens: scoped user-level API credentials for CLI and CI/CD use.
PAT_ENABLED: bool = Field(
default=True,
description=(
"Master switch for personal access tokens. When false, no token can be created AND every existing "
"token stops authenticating immediately (pipelines using them get 401); tokens are kept and work "
"again when re-enabled. Tokens are only available under AUTH_TYPE=oidc or unset (None); switching "
"to simple_jwt or session_jwt disables them the same way."
),
)
PAT_DEFAULT_LIFETIME_DAYS: int = Field(
default=90, gt=0, description="Lifetime of a personal access token created without an explicit expiry."
)
PAT_MAX_LIFETIME_DAYS: int = Field(
default=365, gt=0, description="Longest lifetime a user may request for a personal access token."
)
PAT_ALLOW_NON_EXPIRING: bool = Field(
default=False,
description="Let users create personal access tokens that never expire. Off by default.",
)
PAT_MAX_PER_USER: int = Field(
default=25, gt=0, description="Maximum number of live personal access tokens per user."
)
@field_validator("AUTH_TYPE", mode="before")
@classmethod
def _normalize_auth_type(cls, v):
# Unset spellings ("None", "") became None on the group base; this only case-folds a value.
return normalize_choice(v)
@model_validator(mode="after")
def _require_dependent_settings(self):
if self.AUTH_TYPE == "oidc":
missing = [name for name in OIDC_REQUIRED if not getattr(self, name)]
if missing:
raise ValueError(f"AUTH_TYPE=oidc requires settings: {', '.join(missing)}")
if self.SCIM_ENABLED and not self.SCIM_TOKEN:
raise ValueError("SCIM_ENABLED requires settings: SCIM_TOKEN")
if self.PAT_DEFAULT_LIFETIME_DAYS > self.PAT_MAX_LIFETIME_DAYS:
raise ValueError("PAT_DEFAULT_LIFETIME_DAYS must not exceed PAT_MAX_LIFETIME_DAYS")
return self
+51
View File
@@ -0,0 +1,51 @@
"""OAuth credentials for external source connectors."""
from __future__ import annotations
from typing import Optional
from pydantic import Field
from docsgpt.core.settings._shared import SettingsGroup
class ConnectorSettings(SettingsGroup):
"""Client credentials and callback URLs for Google Drive, Microsoft, Confluence, GitHub and MCP."""
# Google Drive integration.
GOOGLE_CLIENT_ID: Optional[str] = Field(default=None, description="Google OAuth client id.")
GOOGLE_CLIENT_SECRET: Optional[str] = Field(default=None, description="Google OAuth client secret.")
CONNECTOR_REDIRECT_BASE_URI: str = Field(
default="http://127.0.0.1:7091/api/connectors/callback",
description="OAuth callback URL; register it as-is in your provider's console (e.g. GCP).",
)
CONNECTOR_ALLOWED_ORIGINS: Optional[str] = Field(
default=None,
description=(
"Comma-separated frontend origins allowed to receive connector OAuth results, e.g. "
"https://docsgpt.example.com. The callback origin and OIDC_FRONTEND_URL are always allowed; a "
"loopback callback also allows localhost:5173."
),
)
# Microsoft Entra ID (Azure AD) integration.
MICROSOFT_CLIENT_ID: Optional[str] = Field(default=None, description="Azure AD application (client) id.")
MICROSOFT_CLIENT_SECRET: Optional[str] = Field(default=None, description="Azure AD application client secret.")
MICROSOFT_TENANT_ID: str = Field(
default="common", description="Azure AD tenant id, or 'common' for multi-tenant."
)
MICROSOFT_AUTHORITY: Optional[str] = Field(
default=None,
description="Authority URL override; unset derives https://login.microsoftonline.com/<MICROSOFT_TENANT_ID>.",
)
# Confluence Cloud integration.
CONFLUENCE_CLIENT_ID: Optional[str] = Field(default=None, description="Confluence Cloud OAuth client id.")
CONFLUENCE_CLIENT_SECRET: Optional[str] = Field(default=None, description="Confluence Cloud OAuth client secret.")
# GitHub source.
GITHUB_ACCESS_TOKEN: Optional[str] = Field(default=None, description="GitHub PAT with read access to repositories.")
MCP_OAUTH_REDIRECT_URI: Optional[str] = Field(
default=None, description="Public callback URL for MCP OAuth; unset derives it from CONNECTOR_REDIRECT_BASE_URI."
)
+36
View File
@@ -0,0 +1,36 @@
"""User-data Postgres and schema management at startup."""
from __future__ import annotations
from typing import Optional
from pydantic import Field, field_validator
from docsgpt.core.db_uri import normalize_postgres_uri
from docsgpt.core.settings._shared import SettingsGroup
class DatabaseSettings(SettingsGroup):
"""The Postgres database holding users, conversations and sources, and what startup may do to it."""
POSTGRES_URI: Optional[str] = Field(default=None, description="User-data Postgres connection URI.")
AUTO_MIGRATE: bool = Field(
default=True,
description="On startup, apply pending Alembic migrations. Disable if you manage schema out-of-band.",
)
AUTO_CREATE_DB: bool = Field(
default=True,
description="On startup, create the target Postgres database if missing (needs CREATEDB privilege).",
)
AUTO_VECTOR_SCHEMA: bool = Field(
default=True,
description=(
"On startup, create the pgvector/graph tables and verify the embedding dimension. No Alembic "
"migration covers the vector DB (it may be a separate cluster); set False to manage it yourself."
),
)
@field_validator("POSTGRES_URI", mode="before")
@classmethod
def _normalize_postgres_uri(cls, v):
return normalize_postgres_uri(v)
+88
View File
@@ -0,0 +1,88 @@
"""Embedding model selection and where it runs."""
from __future__ import annotations
from typing import Literal, Optional
from pydantic import Field, field_validator
from docsgpt.core.paths import home_dir
from docsgpt.core.settings._shared import SettingsGroup, normalize_choice
class EmbeddingsSettings(SettingsGroup):
"""The embedding model, remote or local, and the batching around it."""
EMBEDDINGS_NAME: str = Field(
default="huggingface_sentence-transformers/all-mpnet-base-v2",
description=(
"Embedding model. The legacy model is the default on purpose: an install that never pinned this "
"has vectors from it, and granite is the same width so a swap would fail silently. New installs "
"get granite from .env-template; existing ones switch by setting this and running "
"docsgpt.scripts.reembed."
),
)
EMBEDDINGS_BASE_URL: Optional[str] = Field(
default=None, description="Remote embeddings API URL (OpenAI-compatible)."
)
EMBEDDINGS_KEY: Optional[str] = Field(
default=None, description="API key for embeddings (with OpenAI, the same value as API_KEY)."
)
EMBEDDINGS_MAX_INPUT_TOKENS: Optional[int] = Field(
default=None, description="Truncate each remote embed input to N tokens (overflow is lost)."
)
EMBEDDINGS_BATCH_SIZE: int = Field(
default=32, ge=1, description="Chunks per store transaction and per remote embed request."
)
EMBEDDINGS_MODEL_BATCH_SIZE: int = Field(
default=1,
ge=1,
description=(
"Documents per local ONNX forward pass. Each pass pads to its longest input, and that waste grows "
"with the square of chunk length: at 1250 tokens, 32 peaked at 6.6 GB, 1 at 2.9 GB."
),
)
EMBEDDINGS_THREADS: Optional[int] = Field(
default=None,
description=(
"Intra-op threads for the local ONNX runner; unset uses every core. It scales sub-linearly, so "
"several single-threaded workers beat one many-threaded process on the same cores."
),
)
EMBEDDINGS_CACHE_DIR: Optional[str] = Field(
default_factory=lambda: str(home_dir() / "models"),
description=(
"Where embedding models and their tokenizers are cached. Persistent by default: FastEmbed's own "
"default is the temp dir."
),
)
EMBEDDINGS_POOLING: Optional[Literal["cls", "mean"]] = Field(
default=None,
description=(
'Pooling strategy ("cls" or "mean"). Read from the model\'s own repository; set only for a '
"repository that declares none, or to override what it declares."
),
)
EMBEDDINGS_NORMALIZE: Optional[bool] = Field(
default=None,
description=(
"L2-normalise embeddings. Read from the model's own repository; set only for a repository that "
"declares nothing, or to override what it declares."
),
)
EMBEDDINGS_DELEGATE_TO_WORKER: bool = Field(
default=True,
description=(
"Embed on the worker so the API holds no model (~890 MB), at one broker round trip per query. "
"Ignored when EMBEDDINGS_BASE_URL is set, which is the better answer for production."
),
)
EMBEDDINGS_QUEUE: str = Field(default="embeddings", description="Celery queue the embed task is routed to.")
EMBEDDINGS_DELEGATE_TIMEOUT: int = Field(
default=60, gt=0, description="Seconds the API waits for the worker to return an embedding."
)
@field_validator("EMBEDDINGS_POOLING", mode="before")
@classmethod
def _normalize_pooling(cls, v):
return normalize_choice(v)
+90
View File
@@ -0,0 +1,90 @@
"""Server-sent events, replay journal and remote-device sessions."""
from __future__ import annotations
from pydantic import Field
from docsgpt.core.settings._shared import SettingsGroup
class EventsSettings(SettingsGroup):
"""The internal push channel (notifications and durable replay) and the Redis pool behind it."""
ENABLE_SSE_PUSH: bool = Field(
default=True,
description=(
"Internal SSE push channel (notifications and durable replay journal). False makes /api/events emit "
'"push_disabled" and return; clients fall back to polling.'
),
)
EVENTS_STREAM_MAXLEN: int = Field(
default=1000, ge=1, description="Per-user durable backlog cap in entries; ~24h of replay at typical rates."
)
SSE_KEEPALIVE_SECONDS: int = Field(default=15, ge=1, description="Interval between SSE keepalive comments.")
SSE_MAX_CONCURRENT_PER_USER: int = Field(
default=8,
ge=0,
description=(
"Simultaneous SSE connections per user; each holds a pooled async Redis connection for its lifetime. "
"8 covers multi-tab use without one user starving the pool. 0 disables."
),
)
ASYNC_REDIS_MAX_CONNECTIONS: int = Field(
default=2000,
ge=1,
description=(
"Pool size of the async Redis client behind the event-loop routes, per process. Every open "
"notification tab, chat reconnect and device session holds one connection, so this caps concurrent "
"streams per worker (redis-py's own default is 100). Keep the total across workers below the Redis "
"server's maxclients (10000 by default)."
),
)
EVENTS_REPLAY_MAX_PER_REQUEST: int = Field(
default=200,
ge=1,
description=(
"Backlog entries XRANGE returns per /api/events snapshot. Bounds what one replay moves from Redis to "
"the wire: a client looping Last-Event-ID reconnects enumerates at most this many per round-trip."
),
)
EVENTS_REPLAY_MAX_AGE_HOURS: int = Field(default=48, description="Oldest backlog entry a replay will return.")
EVENTS_REPLAY_BUDGET_REQUESTS_PER_WINDOW: int = Field(
default=30,
description=(
"Sliding-window cap on snapshot replays per user; exhausting it returns 429 with the cursor pinned "
"so the client backs off until the window rolls over."
),
)
EVENTS_REPLAY_BUDGET_WINDOW_SECONDS: int = Field(default=60, description="Length of the replay budget window.")
MESSAGE_EVENTS_RETENTION_DAYS: int = Field(
default=14,
gt=0,
description=(
"Retention for the message_events journal, enforced by the cleanup_message_events beat task. Replay "
"only needs streams a client could still be tailing."
),
)
# Remote Device feature.
REMOTE_DEVICE_SESSION_IDLE_SECONDS: int = Field(
default=60, gt=0, description="Seconds without a heartbeat before a remote-device session is considered idle."
)
REMOTE_DEVICE_REQUIRE_SIGNATURE: bool = Field(
default=False, description="Require signed commands from remote devices."
)
REMOTE_DEVICE_PAIRING_TTL_SECONDS: int = Field(default=600, gt=0, description="Lifetime of a pairing code.")
REMOTE_DEVICE_CMD_QUEUE_TTL_SECONDS: int = Field(
default=900,
gt=605,
description=(
"Redis TTL of the per-device command queue, routing invocations cross-process so a scheduled run "
"reaches the web-held device session. Must exceed the max drain deadline (605s) so a command for a "
"briefly-offline device isn't evicted before its own drain gives up."
),
)
REMOTE_DEVICE_INVOCATION_TTL_SECONDS: int = Field(
default=900, gt=0, description="Redis TTL of a pending remote-device invocation."
)
REMOTE_DEVICE_OUTPUT_STREAM_MAXLEN: int = Field(
default=10_000, description="Cap on buffered output entries per remote-device invocation stream."
)
+40
View File
@@ -0,0 +1,40 @@
"""Agent guardrails."""
from __future__ import annotations
from typing import Any, Optional
from pydantic import Field
from docsgpt.core.settings._shared import SettingsGroup
class GuardrailSettings(SettingsGroup):
"""Input/output checks every agent runs, and the floor no agent may weaken."""
GUARDRAILS_ENABLED: bool = Field(default=True, description="Master switch; False disables every stage.")
GUARDRAILS_CHECKS_ENABLED: list[str] = Field(
default=[], description="Allowlist of GuardrailCreator.checks keys; empty means every registered check."
)
GUARDRAILS_FLOOR: dict[str, Any] = Field(
default={},
description=(
"A GuardrailsConfig fragment every agent inherits and cannot weaken; agents may add controls or "
'make an action stricter, never looser. "enabled" is required; without it the floor parses but '
'applies to nothing. Example: {"enabled": true, "mode": "scan_all", "controls": [{"check": '
'"secrets", "stage": "output", "action": "redact"}]}'
),
)
GUARDRAILS_JUDGE_MODEL: Optional[str] = Field(
default=None, description="Judge model for the topic/policy checks; unset reuses the request's model."
)
GUARDRAILS_STORE_SCANNED_TEXT: bool = Field(
default=False,
description=(
"Persist scanned text alongside guardrail_events. Off by default: pre-redaction text is exactly the "
"material a PII control exists to keep out of storage."
),
)
GUARDRAILS_EVENTS_RETENTION_DAYS: int = Field(
default=30, ge=1, description="Days guardrail events are kept before the cleanup task removes them."
)
+152
View File
@@ -0,0 +1,152 @@
"""Uploads, document parsing and the size caps that keep one file from taking a worker down."""
from __future__ import annotations
from typing import Literal
from pydantic import Field, field_validator
from docsgpt.core.settings._shared import SettingsGroup, normalize_choice
class IngestionSettings(SettingsGroup):
"""Upload limits, the parser engine, and per-format byte caps for ingestion and attachments."""
UPLOAD_FOLDER: str = Field(default="inputs", description="Directory under the data home for uploaded sources.")
UPLOAD_MAX_REQUEST_BYTES: int = Field(
default=256 * 1024 * 1024,
gt=0,
description="Cap on an upload request body; applied by Flask before multipart parsing.",
)
UPLOAD_MAX_FILE_BYTES: int = Field(
default=100 * 1024 * 1024, gt=0, description="Cap on a single uploaded file; also enforced while copying."
)
PARSE_SPEC_MAX_BYTES: int = Field(
default=10 * 1024 * 1024, gt=0, description="Cap on an OpenAPI/tool spec file accepted for parsing."
)
# ZIP limits apply cumulatively across nested archives in one extraction.
UPLOAD_MAX_ARCHIVE_BYTES: int = Field(
default=250 * 1024 * 1024, gt=0, description="Cap on total bytes extracted from one uploaded archive."
)
UPLOAD_MAX_ARCHIVE_FILES: int = Field(
default=10_000, gt=0, description="Cap on files extracted from one uploaded archive."
)
UPLOAD_MAX_ARCHIVE_RATIO: int = Field(
default=1000, gt=0, description="Maximum decompressed-to-compressed ratio before an archive is rejected."
)
UPLOAD_MAX_ARCHIVE_DEPTH: int = Field(
default=3, ge=0, description="Maximum nesting depth of archives inside archives."
)
PARSE_PDF_AS_IMAGE: bool = Field(default=False, description="Render PDF pages to images before parsing.")
PARSE_IMAGE_REMOTE: bool = Field(default=False, description="Send images to a remote parser.")
DOC_PARSER_ENGINE: Literal["anydoc", "docling"] = Field(
default="anydoc",
description=(
'Document parser for source ingestion, chat attachments and the read_document tool. "anydoc" '
"(default): firecrawl-anydoc, a Rust converter with no ML models; milliseconds per file, ~100 MB "
'peak RSS. "docling": the layout/table-model pipeline (optional install; needed for '
"read_document's structured output and the docling OCR backend). Files anydoc cannot convert "
"(scanned PDFs, malformed input) fall back to docling when it is installed, otherwise to the native "
"OCR parsers (OCR on) or the legacy parsers. Rollback to the previous behaviour is this one variable."
),
)
DOCLING_PIPELINE_QUEUE_MAX_SIZE: int = Field(
default=2,
description=(
"Pages docling's threaded pipeline buffers in flight; the library default (100) drives worker RSS "
"to ~3 GB on a mid-size PDF."
),
)
DOCLING_COMPILE_TORCH_MODELS: bool = Field(
default=False, description="Let docling torch.compile its models (slower start, faster pages)."
)
DOCLING_TABULAR_MAX_BYTES: int = Field(
default=2_000_000, description="Largest CSV/XLSX docling will parse, in bytes."
)
DOCLING_MARKUP_MAX_BYTES: int = Field(
default=8_000_000, description="Largest HTML/XML docling will parse, in bytes."
)
MARKUP_MAX_BYTES: int = Field(
default=8_000_000,
ge=0,
description=(
"HTML/XHTML larger than this (bytes) are head-truncated before the markdownify parser runs (the "
"anydoc engine's HTML path). The tree that path builds costs ~50x the input (30 MB of HTML measured "
"at 1.6 GB RSS) and the upload cap is 100 MB, so the gate is what keeps one upload from taking the "
"ingest worker down. 0 disables it."
),
)
PDF_TRUST_CHECK: bool = Field(
default=True,
description=(
"Trust-check anydoc's PDF output (docsgpt/parser/file/pdf_trust.py): flag composite (Type0) fonts "
"without a ToUnicode map, and CJK-declaring PDFs whose extracted text has almost no CJK, the two "
"classes where anydoc drops text silently. A flagged file re-parses on the docling fallback when "
"docling is installed; otherwise the anydoc output is kept and the document gets "
'extra_info["parse_warnings"]. ~30 ms per scanned MB.'
),
)
ANYDOC_TABLEIZE: bool = Field(
default=False,
description=(
"Rewrite dot-leader / whitespace-aligned table runs in anydoc's PDF markdown into GFM tables "
"(docsgpt/parser/file/tableize.py). Off by default: it rewrites content on a heuristic (>=3 uniform "
"label+numbers lines) validated only on a small corpus so far."
),
)
ATTACHMENT_PDF_TEXT_FAST_PATH: bool = Field(
default=True,
description=(
"Read PDF attachments via their embedded text layer (pypdfium2) instead of docling, falling back to "
"docling when there is no text layer. Attachments go into a prompt, so docling's structural "
"markdown earns far less than the tens of seconds per file it costs; source ingestion is "
"unaffected because chunking and retrieval do depend on that structure."
),
)
ATTACHMENT_PDF_TEXT_MIN_MEDIAN_CHARS: int = Field(
default=32,
description=(
"Median chars per sampled page below which a PDF attachment is treated as a scan and handed to "
"docling. Measured on real uploads: scans at 0-17 chars/page, text-layer documents at 433-6834."
),
)
ATTACHMENT_TEXT_MAX_BYTES: int = Field(default=5_000_000, description="Cap on extracted attachment text.")
AGENT_IMAGE_MAX_BYTES: int = Field(default=5_000_000, description="Cap on an image passed to an agent.")
AGENT_IMAGE_MAX_PIXELS: int = Field(
default=16_777_216, description="Cap on the pixel count of an image passed to an agent."
)
GITHUB_INGEST_MAX_FILE_BYTES: int = Field(
default=1048576, ge=0, description="Skip GitHub repo blobs larger than this (0 = no cap)."
)
GITHUB_INGEST_MAX_WORKERS: int = Field(default=8, ge=1, description="Parallel file fetches per GitHub repo ingest.")
# read_document parsing on a dedicated Celery queue (backend parser).
DOCUMENT_PARSE_QUEUE: str = Field(default="parsing", description="Celery queue the parse_document task is routed to.")
DOCUMENT_PARSE_TIMEOUT: int = Field(
default=120, description="Seconds the read_document tool awaits the enqueued parse before degrading."
)
DOCUMENT_PARSE_TIMEOUT_PER_MB: int = Field(
default=60,
description=(
"Extra seconds of parse window per MiB of input. The base timeout is a FLOOR: the window grows with "
"document size because OCR cost scales with pages. Without this a large scan is silently dropped at "
"the base window."
),
)
DOCUMENT_PARSE_TIMEOUT_MAX: int = Field(
default=900, description="Absolute ceiling on the size-scaled parse window, in seconds."
)
DOCUMENT_PARSE_MAX_BYTES: int = Field(
default=0, ge=0, description="Cap on a parsed document's bytes (0 = reuse SANDBOX_MAX_INPUT_BYTES)."
)
DOCUMENT_MAX_DECOMPRESSED_BYTES: int = Field(
default=300 * 1024 * 1024, description="Cap on bytes decompressed from an archive handed to read_document."
)
DOCUMENT_MAX_ARCHIVE_ENTRIES: int = Field(
default=10000, description="Cap on entries in an archive handed to read_document."
)
@field_validator("DOC_PARSER_ENGINE", mode="before")
@classmethod
def _normalize_parser_engine(cls, v):
return normalize_choice(v)
+106
View File
@@ -0,0 +1,106 @@
"""LLM providers, API keys and per-provider tunables."""
from __future__ import annotations
import os
from typing import Optional
from pydantic import Field
from docsgpt.core.paths import home_dir
from docsgpt.core.settings._shared import SettingsGroup
class LLMSettings(SettingsGroup):
"""Which model answers, how it is reached, and provider-specific behaviour."""
LLM_PROVIDER: str = Field(default="docsgpt", description="LLM provider key, e.g. openai, anthropic, docsgpt.")
LLM_NAME: Optional[str] = Field(
default=None, description="Model name for the provider; with openai, e.g. gpt-4 or gpt-3.5-turbo."
)
API_KEY: Optional[str] = Field(default=None, description="LLM API key used by LLM_PROVIDER.")
# Provider-specific API keys (for multi-model support).
OPENAI_API_KEY: Optional[str] = Field(default=None, description="OpenAI API key.")
ANTHROPIC_API_KEY: Optional[str] = Field(default=None, description="Anthropic API key.")
GOOGLE_API_KEY: Optional[str] = Field(default=None, description="Google AI API key.")
GROQ_API_KEY: Optional[str] = Field(default=None, description="Groq API key.")
HUGGINGFACE_API_KEY: Optional[str] = Field(default=None, description="Hugging Face API key.")
OPEN_ROUTER_API_KEY: Optional[str] = Field(default=None, description="OpenRouter API key.")
NOVITA_API_KEY: Optional[str] = Field(default=None, description="Novita API key.")
OPENAI_API_BASE: Optional[str] = Field(default=None, description="Azure OpenAI API base URL.")
OPENAI_API_VERSION: Optional[str] = Field(default=None, description="Azure OpenAI API version.")
AZURE_DEPLOYMENT_NAME: Optional[str] = Field(default=None, description="Azure deployment name for answering.")
AZURE_EMBEDDINGS_DEPLOYMENT_NAME: Optional[str] = Field(
default=None, description="Azure deployment name for embeddings."
)
OPENAI_BASE_URL: Optional[str] = Field(
default=None, description="Base URL for OpenAI-compatible model servers."
)
LLM_PATH: str = Field(
default=os.path.join(str(home_dir()), "models/docsgpt-7b-f16.gguf"),
description="Path to the local GGUF model used by the llama.cpp provider.",
)
FALLBACK_LLM_PROVIDER: Optional[str] = Field(default=None, description="Provider for the fallback LLM.")
FALLBACK_LLM_NAME: Optional[str] = Field(default=None, description="Model name for the fallback LLM.")
FALLBACK_LLM_API_KEY: Optional[str] = Field(default=None, description="API key for the fallback LLM.")
TITLE_MODEL_ID: Optional[str] = Field(
default=None, description="Optional cheaper model for conversation titles; unset reuses the answer model."
)
MODELS_CONFIG_DIR: Optional[str] = Field(
default=None,
description=(
"Directory of operator-supplied model YAMLs, loaded after the built-in catalog; later wins on "
"duplicate model id. See docsgpt/core/models/README.md."
),
)
DEFAULT_LLM_TOKEN_LIMIT: int = Field(
default=128000, description="Context window assumed when the model is not found in the registry."
)
RESERVED_TOKENS: dict[str, int] = Field(
default={"system_prompt": 500, "current_query": 500, "safety_buffer": 1000},
description="Tokens held back from the context window for the system prompt, the query and a safety buffer.",
)
CACHE_REDIS_URL: str = Field(default="redis://localhost:6379/2", description="Redis URL for the LLM cache.")
# OpenAI Responses API.
OPENAI_RESPONSES_STORE: bool = Field(
default=False,
description=(
"True persists Responses API calls server-side so previous_response_id can chain turns. False keeps "
"them stateless, carrying reasoning across the tool loop as encrypted items."
),
)
OPENAI_RESPONSES_CHAIN_ACROSS_TURNS: bool = Field(
default=True,
description=(
"Cross-turn previous_response_id chaining (store mode only). The chained transcript lives on the "
"provider and is invisible to every local guard, so it is bounded: a turn starts from the local "
"history when the previous turn's reported prompt already reached the budget (default: the model's "
"context window) or when the conversation was compressed after that turn was produced."
),
)
OPENAI_RESPONSES_CHAIN_BUDGET_TOKENS: Optional[int] = Field(
default=None, description="Prompt-token budget for cross-turn chaining; unset uses the model's context window."
)
OPENAI_RESPONSES_TRUNCATION_AUTO: bool = Field(
default=False,
description=(
'Send truncation: "auto" so the provider drops the oldest input items instead of failing every '
"request once a chain exceeds the model's window."
),
)
OPENAI_PROMPT_CACHE_KEY: bool = Field(
default=True,
description=(
"Route a user's Responses API calls to the same prompt-cache shard with an opaque per-user key."
),
)
OPENAI_PROMPT_CACHE_RETENTION: Optional[str] = Field(
default=None, description="Request extended prompt-cache retention where the provider offers it."
)
OPENAI_REASONING_SUMMARY: str = Field(
default="auto", description="Reasoning summary mode requested from the Responses API."
)
+99
View File
@@ -0,0 +1,99 @@
"""OCR for scanned PDFs and images."""
from __future__ import annotations
from typing import Literal
from pydantic import AliasChoices, Field, field_validator
from docsgpt.core.settings._shared import SettingsGroup, normalize_choice
class OCRSettings(SettingsGroup):
"""Whether OCR runs, which stack performs it, and which engine it uses.
OCR_ENABLED covers source ingestion, OCR_ATTACHMENTS_ENABLED chat attachments. Which stack performs
it is OCR_BACKEND; which engine, OCR_ENGINE. The DOCLING_OCR_* names are the pre-2026-09 spellings
and stay accepted as aliases.
"""
OCR_ENABLED: bool = Field(
default=False,
validation_alias=AliasChoices("OCR_ENABLED", "DOCLING_OCR_ENABLED"),
description="OCR scanned PDFs and images during source ingestion.",
)
OCR_ATTACHMENTS_ENABLED: bool = Field(
default=False,
validation_alias=AliasChoices("OCR_ATTACHMENTS_ENABLED", "DOCLING_OCR_ATTACHMENTS_ENABLED"),
description="OCR scanned PDFs and images attached to a chat.",
)
OCR_BACKEND: Literal["auto", "docling", "native"] = Field(
default="auto",
description=(
"Which stack runs OCR when it is on. auto: docling when installed, otherwise native. docling: the "
"layout-model pipeline (hybrid region OCR, reading order, table structure); needs the optional "
"docling extra. native: pypdfium2/Pillow page rendering straight into tesseract or a DeepSeek-OCR "
"endpoint (docsgpt/parser/file/ocr_parser.py); no ML models in the worker, tables come out as text "
"lines under tesseract."
),
)
OCR_ENGINE: Literal["tesseract", "deepseek", "auto", "ocrmac", "rapidocr"] = Field(
default="tesseract",
description=(
"OCR engine used when OCR is on. Benched 2026-08 on EN/ZH/table/degraded scans (docs/Guides/ocr has "
"the menu). tesseract (recommended): best classic-engine accuracy (perfect EN word recall, 0.000 "
"bilingual CER, 100% table cells), ~35 MB, CPU-only; needs the system binary and language packs, an "
"optional install like every OCR dependency (build with INSTALL_TESSERACT=true, or apt/brew install "
"tesseract-ocr for a local run); both backends. deepseek: DeepSeek-OCR against an Ollama/vLLM "
"endpoint (OCR_DEEPSEEK_*); best table/CJK quality, the worker stays light (no layout models) but "
"each page costs seconds on the model server; both backends. auto: docling's pick, ocrmac on macOS "
"(excellent), rapidocr on Linux (silently shreds some long text lines; avoid as a server default). "
"ocrmac | rapidocr: force one of those. auto/ocrmac/rapidocr exist only inside docling; the native "
"backend runs tesseract for them. An engine that is not installed degrades (docling: to auto) with "
"a warning instead of failing the parse."
),
)
OCR_LANGS: str = Field(
default="eng",
description=(
'Tesseract language packs, "+"-separated (e.g. "eng+chi_sim+deu"). Other engines keep their own '
"defaults; their language codes differ."
),
)
OCR_DEEPSEEK_URL: str = Field(
default="http://localhost:11434/v1/chat/completions",
description="Chat-completions URL of the DeepSeek-OCR endpoint (Ollama or vLLM).",
)
OCR_DEEPSEEK_MODEL: str = Field(default="deepseek-ocr:3b", description="Model name at the DeepSeek-OCR endpoint.")
OCR_DEEPSEEK_TIMEOUT: float = Field(
default=300.0,
description=(
"Seconds allowed per page request to the DeepSeek endpoint, on both backends (native sends pages one "
"at a time; docling's VLM pipeline keeps its own concurrency). A 3B model on a laptop needs minutes; "
"a vLLM GPU deployment, seconds."
),
)
OCR_RENDER_DPI: int = Field(
default=200,
description=(
"Native backend only: resolution at which pages without a text layer are rendered before OCR. 200 "
"suits tesseract; clamped to 72-600."
),
)
OCR_MIN_CHARS_PER_PAGE: int = Field(
default=20,
ge=0,
validation_alias=AliasChoices("OCR_MIN_CHARS_PER_PAGE", "DOCLING_OCR_MIN_CHARS_PER_PAGE"),
description=(
"Chars-per-page floor below which an OCR'd PDF/image parse is treated as an OCR dropout rather than "
"as content (long-running docling workers were observed returning zero characters for every "
"scanned page after a long scanned PDF, with no error). docling retries once on a fresh full-page-OCR "
"converter; both backends then fail loudly instead of indexing an empty document. 0 disables the "
"guard."
),
)
@field_validator("OCR_BACKEND", "OCR_ENGINE", mode="before")
@classmethod
def _normalize_ocr_choices(cls, v):
return normalize_choice(v)
+36
View File
@@ -0,0 +1,36 @@
"""Admin-set usage quotas and the pricing that feeds their cost budgets."""
from __future__ import annotations
from typing import Literal, Optional
from pydantic import Field, field_validator
from docsgpt.core.settings._shared import SettingsGroup
class QuotaSettings(SettingsGroup):
"""Quota window and the treatment of unpriced models."""
QUOTA_PERIOD: Literal["day", "week", "month"] = Field(
default="month",
description=(
"Window every usage quota is measured over. Windows are calendar-aligned in UTC: "
"a day starts at 00:00, a week on Monday, a month on the 1st."
),
)
QUOTA_UNPRICED_RATE_PER_MILLION: Optional[list[float]] = Field(
default=None,
description=(
"Fallback `[input, output]` USD rates per 1M tokens for models that declare no price, "
"e.g. `[0.5, 1.5]`. Unset, such calls are recorded at $0 and only count toward token quotas."
),
)
@field_validator("QUOTA_UNPRICED_RATE_PER_MILLION")
@classmethod
def _two_non_negative_rates(cls, v: Optional[list[float]]) -> Optional[list[float]]:
if v is None:
return None
if len(v) != 2 or any(rate < 0 for rate in v):
raise ValueError("QUOTA_UNPRICED_RATE_PER_MILLION must be two non-negative numbers")
return v
+172
View File
@@ -0,0 +1,172 @@
"""Render the settings reference page from the ``Settings`` definitions.
The page under ``docs/content/Deploying/Settings-Reference.mdx`` is generated
from the field types, defaults and descriptions in this package, so the model
is the single source of truth. Regenerate it after changing a setting::
python -m docsgpt.core.settings.reference --write
``--check`` exits non-zero when the checked-in page is stale; the test suite
runs the same comparison.
"""
from __future__ import annotations
import argparse
import inspect
import json
import sys
import types
import typing
from pathlib import Path
from typing import Any, Literal, Optional, Union
from pydantic import AliasChoices
from pydantic.fields import FieldInfo
from docsgpt.core.paths import home_dir
from docsgpt.core.settings import SETTINGS_GROUPS, Settings
REFERENCE_PATH = Path("docs") / "content" / "Deploying" / "Settings-Reference.mdx"
HOME_PLACEHOLDER = "<DOCSGPT_HOME>"
_HEADER = """\
---
title: Settings Reference
description: Every DocsGPT setting, grouped by domain, with its type, default and purpose.
---
{/* GENERATED FILE. Do not edit by hand: run `python -m docsgpt.core.settings.reference --write`. */}
# Settings Reference
Every setting DocsGPT reads, generated from `docsgpt/core/settings/`. Each one is
an environment variable of the same name, set in `.env` or the process
environment; see [App Configuration](/Deploying/DocsGPT-Settings) for how the
file is found and for worked examples. `<DOCSGPT_HOME>` below is the data home
described there.
"""
def _mdx(text: str) -> str:
"""Escape prose for MDX, where braces open expressions and ``<`` opens JSX."""
return text.replace("{", "\\{").replace("}", "\\}").replace("<", "&lt;")
def _type_name(annotation: Any) -> str:
origin = typing.get_origin(annotation)
if origin in (Union, types.UnionType):
args = [a for a in typing.get_args(annotation) if a is not type(None)]
return " | ".join(_type_name(a) for a in args)
if origin is Literal:
return " | ".join(json.dumps(v) for v in typing.get_args(annotation))
if origin is not None:
name = getattr(origin, "__name__", str(origin))
args = typing.get_args(annotation)
return f"{name}[{', '.join(_type_name(arg) for arg in args)}]" if args else name
return getattr(annotation, "__name__", str(annotation))
def _default_text(field: FieldInfo) -> str:
value = field.default_factory() if field.default_factory is not None else field.default
if value is None:
return "unset"
if isinstance(value, bool):
return "`true`" if value else "`false`"
if isinstance(value, str):
value = value.replace(str(home_dir()), HOME_PLACEHOLDER)
return "`\"\"`" if value == "" else f"`{value}`"
if isinstance(value, (list, dict)):
return f"`{json.dumps(value)}`"
return f"`{value}`"
def _constraints(field: FieldInfo) -> list[str]:
out = []
for item in field.metadata:
for attr, symbol in (("gt", ">"), ("ge", ">="), ("lt", "<"), ("le", "<=")):
if hasattr(item, attr):
out.append(f"{symbol} {getattr(item, attr)}")
return out
def _aliases(name: str, field: FieldInfo) -> list[str]:
alias = field.validation_alias
if isinstance(alias, AliasChoices):
return [str(c) for c in alias.choices if str(c) != name]
if isinstance(alias, str) and alias != name:
return [alias]
return []
def _render_field(name: str, field: FieldInfo) -> str:
facts = [f"Type `{_type_name(field.annotation)}`", f"default {_default_text(field)}"]
constraints = _constraints(field)
if constraints:
# Code spans: a bare ``<=`` in MDX prose is parsed as the start of a JSX tag.
facts.append("must be " + " and ".join(f"`{c}`" for c in constraints))
aliases = _aliases(name, field)
if aliases:
facts.append("also read from " + ", ".join(f"`{a}`" for a in aliases))
# The facts are code spans, which MDX leaves alone; only prose needs escaping.
lines = [f"### `{name}`", "", ", ".join(facts) + "."]
if field.deprecated:
note = field.deprecated if isinstance(field.deprecated, str) else "This setting is deprecated."
lines += ["", f"**Deprecated.** {_mdx(str(note))}"]
if field.description:
lines += ["", _mdx(field.description)]
return "\n".join(lines)
def _group_intro(group: type) -> str:
doc = inspect.getdoc(group) or ""
return doc.split("\n\n", 1)[0].replace("\n", " ").strip()
def render_reference() -> str:
"""The full reference page as MDX text."""
parts = [_HEADER]
for title, group in SETTINGS_GROUPS:
parts.append(f"\n## {title}\n")
intro = _group_intro(group)
if intro:
parts.append(_mdx(intro) + "\n")
for name in group.model_fields:
parts.append(_render_field(name, Settings.model_fields[name]) + "\n")
return "\n".join(parts).rstrip("\n") + "\n"
def reference_path(root: Optional[Path] = None) -> Path:
"""Where the generated page lives in a checkout; ``root`` defaults to the repository root."""
if root is None:
root = Path(__file__).resolve().parents[3]
return root / REFERENCE_PATH
def main(argv: Optional[list[str]] = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0])
action = parser.add_mutually_exclusive_group()
action.add_argument("--write", action="store_true", help="write the page into the docs tree")
action.add_argument("--check", action="store_true", help="exit 1 if the checked-in page is stale")
args = parser.parse_args(argv)
rendered = render_reference()
path = reference_path()
if args.write:
path.write_text(rendered, encoding="utf-8")
print(f"wrote {path}")
return 0
if args.check:
current = path.read_text(encoding="utf-8") if path.exists() else ""
if current != rendered:
print(f"{path} is stale; run: python -m docsgpt.core.settings.reference --write", file=sys.stderr)
return 1
print(f"{path} is up to date")
return 0
sys.stdout.write(rendered)
return 0
if __name__ == "__main__":
sys.exit(main())
+47
View File
@@ -0,0 +1,47 @@
"""Retrieval strategy and GraphRAG."""
from __future__ import annotations
from typing import Literal, Optional
from pydantic import Field, field_validator
from docsgpt.core.settings._shared import SettingsGroup, normalize_choice
class RetrievalSettings(SettingsGroup):
"""Which vector store answers searches and how retrieval fans out across sources."""
VECTOR_STORE: Literal["faiss", "elasticsearch", "mongodb", "qdrant", "milvus", "pgvector"] = Field(
default="faiss", description="Vector store backend."
)
RETRIEVAL_MAX_PARALLEL_SOURCES: int = Field(
default=4,
ge=1,
description="Concurrent per-source searches in one retrieval; the query is embedded once and shared.",
)
PER_SOURCE_RETRIEVAL_ENABLED: bool = Field(
default=True,
description="Kill-switch for per-source retrieval dispatch; False collapses to a single retriever.",
)
GRAPHRAG_ENABLED: bool = Field(default=False, description="Gates graph-aware ingestion and retrieval.")
GRAPHRAG_EXTRACTION_MODEL: Optional[str] = Field(
default=None, description="Model for ingest-time graph extraction; unset reuses LLM_PROVIDER/LLM_NAME."
)
GRAPHRAG_MAX_CHUNKS_FOR_EXTRACTION: int = Field(
default=2000, ge=0, description="Hard cap on chunks extracted per source (cost control); 0 extracts nothing."
)
GRAPHRAG_EXTRACTION_WORKERS: int = Field(
default=8,
ge=1,
le=32,
description=(
"Concurrent extraction calls during ingest. Model calls run in parallel while "
"graph writes stay serial, so ordering and idempotency are unchanged; 1 is fully serial."
),
)
@field_validator("VECTOR_STORE", mode="before")
@classmethod
def _normalize_vector_store(cls, v):
return normalize_choice(v)
+93
View File
@@ -0,0 +1,93 @@
"""Code-execution sandbox: the Jupyter gateway runner or Daytona Cloud."""
from __future__ import annotations
from typing import Literal, Optional
from pydantic import Field, field_validator
from docsgpt.core.settings._shared import SettingsGroup, normalize_choice
class SandboxSettings(SettingsGroup):
"""The app is a CLIENT of an always-on runner; defaults are safe so app import never fails unconfigured."""
SANDBOX_BACKEND: Literal["jupyter", "daytona"] = Field(
default="jupyter", description="Sandbox backend: jupyter (self-host) or daytona (Daytona Cloud)."
)
SANDBOX_GATEWAY_URL: str = Field(
default="http://localhost:8888",
description="URL of the Jupyter Kernel Gateway runner (the docsgpt-sandbox service).",
)
SANDBOX_GATEWAY_AUTH_TOKEN: Optional[str] = Field(default=None, description="Gateway auth token, if set.")
SANDBOX_KERNEL_NAME: str = Field(
default="docsgpt-python",
description=(
"Kernelspec per session. The env-scrubbing docsgpt-python spec keeps kernel code from reading the "
"gateway token or operator secrets from os.environ; the stock python3 spec inherits the gateway env "
"verbatim and must not be used with untrusted code."
),
)
SANDBOX_MAX_TTL: int = Field(default=1200, description="Hard cap (s) on agent-selectable keep-alive TTL.")
SANDBOX_MAX_SESSIONS: int = Field(
default=32,
description=(
"Concurrent live sessions per process, backend-agnostic; at the cap an LRU-idle session is evicted. "
"0 or negative disables the cap."
),
)
SANDBOX_EXEC_TIMEOUT: int = Field(default=60, description="Default wall-clock cap (s) per exec call.")
SANDBOX_HTTP_TIMEOUT: int = Field(
default=10, description="Fixed cap (s) for REST control calls (create/delete/alive/interrupt)."
)
SANDBOX_MAX_OUTPUT_BYTES: int = Field(
default=8 * 1024 * 1024, description="Cap on buffered stdout+stderr per exec."
)
SANDBOX_MAX_FILE_BYTES: int = Field(
default=10 * 1024 * 1024, description="Cap on get_file size routed through stdout."
)
SANDBOX_MAX_INPUT_BYTES: int = Field(
default=25 * 1024 * 1024, description="Cap on an input document staged into a sandbox session."
)
# Runner container caps, consumed by the docsgpt-sandbox compose service, not the app.
SANDBOX_MEMORY: str = Field(
default="1g",
description=(
"Docker mem_limit for the runner container. Consumed by the docsgpt-sandbox compose service, not "
"the app; part of the untrusted-code security boundary."
),
)
SANDBOX_CPUS: str = Field(
default="1.0",
description=(
"Docker CPU quota for the runner container. Consumed by the docsgpt-sandbox compose service, not "
"the app; part of the untrusted-code security boundary."
),
)
# Daytona Cloud backend (SANDBOX_BACKEND=daytona). All knobs are optional so app import never fails
# when the backend is unused.
DAYTONA_API_KEY: Optional[str] = Field(default=None, description="Daytona Cloud API key (secret).")
DAYTONA_API_URL: Optional[str] = Field(
default=None, description="Override for the Daytona API base URL, if self-targeting."
)
DAYTONA_TARGET: Optional[str] = Field(default=None, description='Daytona region/target, e.g. "us".')
DAYTONA_SNAPSHOT: Optional[str] = Field(
default=None,
description="Image for new sandboxes; render libs via scripts/build_daytona_snapshot.py.",
)
DAYTONA_LANGUAGE: str = Field(default="python", description="Default runtime language for created sandboxes.")
DAYTONA_AUTO_STOP_INTERVAL: int = Field(
default=15, ge=0, description="Minutes idle before Daytona auto-stops a sandbox (0 disables)."
)
DAYTONA_AUTO_DELETE_INTERVAL: int = Field(
default=60, ge=-1, description="Minutes after stop before Daytona auto-deletes a sandbox (-1 disables)."
)
DAYTONA_MAX_SANDBOXES: int = Field(
default=50, description="Cap on concurrent live Daytona sandboxes (cost-DoS guard)."
)
@field_validator("SANDBOX_BACKEND", mode="before")
@classmethod
def _normalize_sandbox_backend(cls, v):
return normalize_choice(v)
+28
View File
@@ -0,0 +1,28 @@
"""Scheduled agent runs (see scheduler.md)."""
from __future__ import annotations
from pydantic import Field
from docsgpt.core.settings._shared import SettingsGroup
class SchedulerSettings(SettingsGroup):
"""Cadence, quotas and timeouts of scheduled runs."""
SCHEDULE_DISPATCHER_INTERVAL: int = Field(
default=30, description="Seconds between dispatcher passes that enqueue due schedules."
)
SCHEDULE_MIN_INTERVAL: int = Field(default=900, description="Smallest allowed recurrence interval in seconds.")
SCHEDULE_MAX_PER_USER: int = Field(default=50, description="Cap on schedules a user may own.")
SCHEDULE_RUN_TIMEOUT: int = Field(default=600, description="Wall-clock cap on one scheduled run, in seconds.")
SCHEDULE_MISFIRE_GRACE: int = Field(
default=60, description="Seconds past the due time within which a missed run still fires."
)
SCHEDULE_AUTOPAUSE_FAILURES: int = Field(
default=3, description="Consecutive failures after which a schedule is paused automatically."
)
SCHEDULE_ONCE_MAX_HORIZON: int = Field(
default=31_536_000, description="How far ahead a one-off run may be scheduled, in seconds (one year)."
)
SCHEDULE_RUN_OUTPUT_RETENTION_DAYS: int = Field(default=90, gt=0, description="Days scheduled-run output is kept.")
+46
View File
@@ -0,0 +1,46 @@
"""The API process itself."""
from __future__ import annotations
from typing import Optional
from pydantic import Field
from docsgpt.core.settings._shared import SettingsGroup
class ServerSettings(SettingsGroup):
"""Serving the UI, public URLs, and process-level knobs of the API server."""
DEPLOYMENT_TYPE: Optional[str] = Field(
default=None,
description=(
"Deployment class, e.g. cloud or production. A production class refuses to run without a "
"configured JWT_SECRET_KEY instead of generating a local one on disk."
),
)
SERVE_UI: bool = Field(
default=True, description="Serve the web UI shipped in the package (docsgpt/static) from the API process."
)
FLASK_DEBUG_MODE: bool = Field(default=False, description="Run Flask in debug mode.")
VERSION_CHECK: bool = Field(default=True, description="Anonymous startup version check for security issues.")
PUBLIC_API_BASE_URL: Optional[str] = Field(
default=None, description="Public base URL for user-facing endpoint references in prompts."
)
GRACEFUL_SHUTDOWN_TIMEOUT_SECONDS: int = Field(
default=30,
description=(
"Bounds uvicorn's shutdown drain (uvicorn_worker doesn't forward --graceful-timeout). Keep below the "
"gunicorn --timeout (180) watchdog. Used by BoundedDrainUvicornWorker."
),
)
WSGI_THREADPOOL_WORKERS: int = Field(
default=96, ge=1, description="Threads serving the WSGI (Flask) part of the app under the ASGI server."
)
V1_SESSION_TTL_SECONDS: int = Field(
default=24 * 60 * 60,
description=(
"Lets OpenAI-compatible clients identify a logical chat by session header, which chat-completions "
"itself has no field for; TTL of that session mapping."
),
)
+33
View File
@@ -0,0 +1,33 @@
"""Text-to-speech and speech-to-text."""
from __future__ import annotations
from typing import Literal, Optional
from pydantic import Field, field_validator
from docsgpt.core.settings._shared import SettingsGroup, normalize_choice
class SpeechSettings(SettingsGroup):
"""Voice providers and transcription options."""
TTS_PROVIDER: Literal["google_tts", "elevenlabs", "none"] = Field(
default="google_tts", description="Text-to-speech provider; none switches it off."
)
ELEVENLABS_API_KEY: Optional[str] = Field(default=None, description="ElevenLabs API key.")
STT_PROVIDER: Literal["openai", "faster_whisper", "none"] = Field(
default="openai", description="Speech-to-text provider; none switches it off."
)
OPENAI_STT_MODEL: str = Field(default="gpt-4o-mini-transcribe", description="OpenAI transcription model.")
STT_LANGUAGE: Optional[str] = Field(default=None, description="Language hint for transcription; unset auto-detects.")
STT_MAX_FILE_SIZE_MB: int = Field(default=50, description="Cap on an audio file accepted for transcription.")
STT_ENABLE_TIMESTAMPS: bool = Field(default=False, description="Return word/segment timestamps.")
STT_ENABLE_DIARIZATION: bool = Field(default=False, description="Label speakers in the transcript.")
@field_validator("TTS_PROVIDER", "STT_PROVIDER", mode="before")
@classmethod
def _normalize_speech_providers(cls, v):
# An empty value has always meant "off"; keep that spelling working.
v = normalize_choice(v)
return "none" if v == "" else v
+57
View File
@@ -0,0 +1,57 @@
"""Where uploaded files and generated artifacts are stored."""
from __future__ import annotations
from typing import Literal, Optional
from pydantic import Field, field_validator
from docsgpt.core.settings._shared import SettingsGroup, normalize_choice
class StorageSettings(SettingsGroup):
"""Local disk or an S3-compatible bucket, and how download URLs are produced."""
STORAGE_TYPE: Literal["local", "s3"] = Field(default="local", description="File storage backend.")
URL_STRATEGY: Literal["backend", "s3"] = Field(
default="backend",
description="How download links are produced: backend (streamed through the API) or s3 (presigned URLs).",
)
# S3-compatible object storage (STORAGE_TYPE=s3): AWS S3, MinIO, R2, B2, Spaces, ...
# For non-AWS, set S3_ENDPOINT_URL and usually S3_PATH_STYLE=true.
S3_BUCKET_NAME: str = Field(default="docsgpt-test-bucket", description="Bucket name.")
S3_ENDPOINT_URL: Optional[str] = Field(
default=None, description="Custom endpoint for S3-compatible services (MinIO, R2, B2, Spaces); omit for AWS."
)
S3_ACCESS_KEY_ID: Optional[str] = Field(default=None, description="Access key id.")
S3_SECRET_ACCESS_KEY: Optional[str] = Field(default=None, description="Secret access key.")
S3_REGION: Optional[str] = Field(default=None, description='AWS region; use "auto" for Cloudflare R2.')
S3_PATH_STYLE: bool = Field(
default=False, description="Path-style addressing (required by most non-AWS services)."
)
# Legacy AWS credentials from the retired SageMaker provider.
SAGEMAKER_REGION: Optional[str] = Field(
default=None,
deprecated="Set S3_REGION instead; the SAGEMAKER_* fallback will be removed.",
description="Legacy AWS region from the retired SageMaker provider; deprecated fallback for S3_REGION.",
)
SAGEMAKER_ACCESS_KEY: Optional[str] = Field(
default=None,
deprecated="Set S3_ACCESS_KEY_ID instead; the SAGEMAKER_* fallback will be removed.",
description="Legacy AWS access key from the retired SageMaker provider; deprecated fallback for S3_ACCESS_KEY_ID.",
)
SAGEMAKER_SECRET_KEY: Optional[str] = Field(
default=None,
deprecated="Set S3_SECRET_ACCESS_KEY instead; the SAGEMAKER_* fallback will be removed.",
description=(
"Legacy AWS secret key from the retired SageMaker provider; deprecated fallback for "
"S3_SECRET_ACCESS_KEY."
),
)
@field_validator("STORAGE_TYPE", "URL_STRATEGY", mode="before")
@classmethod
def _normalize_storage_choices(cls, v):
return normalize_choice(v)
+84
View File
@@ -0,0 +1,84 @@
"""Connection settings for each vector store backend."""
from __future__ import annotations
from typing import Optional
from pydantic import Field, field_validator
from docsgpt.core.db_uri import normalize_pgvector_connection_string
from docsgpt.core.paths import home_dir
from docsgpt.core.settings._shared import SettingsGroup
class VectorStoreSettings(SettingsGroup):
"""Per-backend connection details; only the backend named by VECTOR_STORE is read."""
MONGO_URI: Optional[str] = Field(
default=None,
description=(
"Only consulted when VECTOR_STORE=mongodb or when running scripts/db/backfill.py; user data lives "
"in Postgres."
),
)
# Elasticsearch.
ELASTIC_CLOUD_ID: Optional[str] = Field(default=None, description="Elastic Cloud id.")
ELASTIC_USERNAME: Optional[str] = Field(default=None, description="Elasticsearch username.")
ELASTIC_PASSWORD: Optional[str] = Field(default=None, description="Elasticsearch password.")
ELASTIC_URL: Optional[str] = Field(default=None, description="Elasticsearch URL.")
ELASTIC_INDEX: str = Field(default="docsgpt", description="Elasticsearch index name.")
# Qdrant.
QDRANT_COLLECTION_NAME: str = Field(default="docsgpt", description="Qdrant collection name.")
QDRANT_LOCATION: Optional[str] = Field(default=None, description="Qdrant location (':memory:' or a URL).")
QDRANT_URL: Optional[str] = Field(default=None, description="Qdrant server URL.")
QDRANT_PORT: int = Field(default=6333, description="Qdrant REST port.")
QDRANT_GRPC_PORT: int = Field(default=6334, description="Qdrant gRPC port.")
QDRANT_PREFER_GRPC: bool = Field(default=False, description="Use gRPC instead of REST where possible.")
QDRANT_HTTPS: Optional[bool] = Field(default=None, description="Use HTTPS for the Qdrant connection.")
QDRANT_API_KEY: Optional[str] = Field(default=None, description="Qdrant API key.")
QDRANT_PREFIX: Optional[str] = Field(default=None, description="URL prefix for a Qdrant behind a proxy.")
QDRANT_TIMEOUT: Optional[float] = Field(default=None, description="Qdrant request timeout in seconds.")
QDRANT_HOST: Optional[str] = Field(default=None, description="Qdrant host (alternative to QDRANT_URL).")
QDRANT_PATH: Optional[str] = Field(default=None, description="Path for an embedded on-disk Qdrant.")
QDRANT_DISTANCE_FUNC: str = Field(default="Cosine", description="Qdrant distance function.")
# PGVector.
PGVECTOR_CONNECTION_STRING: Optional[str] = Field(
default=None,
description=(
"pgvector connection string. postgres://, postgresql:// and postgresql+psycopg:// are all accepted "
"and normalized internally for psycopg.connect(). Unset falls back to POSTGRES_URI."
),
)
PGVECTOR_POOL_MAX_SIZE: int = Field(
default=8, ge=0, description="Per-process connection pool size; 0 uses one direct connection per store."
)
PGVECTOR_IVFFLAT_PROBES: Optional[int] = Field(
default=None,
description="IVFFlat probes; unset derives sqrt(lists) from the index. Higher means better recall, more scan.",
)
# Milvus.
MILVUS_COLLECTION_NAME: str = Field(default="docsgpt", description="Milvus collection name.")
MILVUS_URI: Optional[str] = Field(
default_factory=lambda: str(home_dir() / "milvus_local.db"),
description=(
"Milvus server URI. The default is a milvus-lite (embedded) database file under the data home, "
"like the other local stores."
),
)
MILVUS_TOKEN: str = Field(default="", description="Milvus auth token.")
# LanceDB.
LANCEDB_PATH: str = Field(
default_factory=lambda: str(home_dir() / "data" / "lancedb"),
description="LanceDB local data directory.",
)
LANCEDB_TABLE_NAME: str = Field(default="docsgpts", description="LanceDB table for stored vectors.")
@field_validator("PGVECTOR_CONNECTION_STRING", mode="before")
@classmethod
def _normalize_pgvector_connection_string(cls, v):
return normalize_pgvector_connection_string(v)
+39
View File
@@ -0,0 +1,39 @@
"""Celery broker, result backend and worker process limits."""
from __future__ import annotations
from pydantic import Field
from docsgpt.core.settings._shared import SettingsGroup
class WorkerSettings(SettingsGroup):
"""How background tasks are queued and how worker processes are recycled."""
CELERY_BROKER_URL: str = Field(default="redis://localhost:6379/0", description="Celery broker URL.")
CELERY_RESULT_BACKEND: str = Field(default="redis://localhost:6379/1", description="Celery result backend URL.")
CELERY_WORKER_PREFETCH_MULTIPLIER: int = Field(
default=1, description="Tasks prefetched per worker process; 1 caps SIGKILL loss to one task."
)
CELERY_VISIBILITY_TIMEOUT: int = Field(
default=3600,
gt=0,
description=(
"Broker visibility timeout in seconds. Must exceed the longest legitimate task runtime but stay "
"short enough that SIGKILLed tasks redeliver promptly."
),
)
CELERY_WORKER_MAX_MEMORY_PER_CHILD: int = Field(
default=4194304,
ge=0,
description=(
"Recycle a prefork child past this resident size in KB; backstops docling/torch heap growth. "
"Checked between tasks, so it does not bound the peak within one. 0 disables."
),
)
CELERY_WORKER_MAX_TASKS_PER_CHILD: int = Field(
default=0, ge=0, description="Recycle a worker child after N tasks; 0 disables."
)
API_URL: str = Field(
default="http://localhost:7091", description="Backend URL the Celery worker calls back into."
)