diff --git a/.env.alfred b/.env.alfred index c82f6d9..8946047 100644 --- a/.env.alfred +++ b/.env.alfred @@ -1,3 +1,13 @@ +# --- IMPORTANT --- +# Settings are split across multiple files for clarity. +# Files (loaded in this order, last wins): +# .env.alfred — app config and service addresses (safe to commit) +# .env.secrets — generated secrets, passwords, URIs and API keys (DO NOT COMMIT) +# .env.make — build metadata synced from pyproject.toml (safe to commit) +# +# To customize: edit .env.alfred for config, .env.secrets for secrets. + +# --- Alfred --- MAX_HISTORY_MESSAGES=10 MAX_TOOL_ITERATIONS=10 REQUEST_TIMEOUT=30 @@ -8,84 +18,58 @@ LLM_TEMPERATURE=0.2 # Persistence DATA_STORAGE_DIR=data -# Network configuration +# Network HOST=0.0.0.0 PORT=3080 -# Build informations (Synced with pyproject.toml via bootstrap) -ALFRED_VERSION= -IMAGE_NAME= -LIBRECHAT_VERSION= -PYTHON_VERSION= -PYTHON_VERSION_SHORT= -RAG_VERSION= -RUNNER= -SERVICE_NAME= - -# --- SECURITY KEYS (CRITICAL) --- -# These are used for session tokens and encrypting sensitive data in MongoDB. -# If you lose these, you lose access to encrypted stored credentials. -JWT_SECRET= -JWT_REFRESH_SECRET= -CREDS_KEY= -CREDS_IV= - -# --- DATABASES (AUTO-SECURED) --- -# Alfred uses MongoDB for application state and PostgreSQL for Vector RAG. -# Passwords will be generated as 24-character secure tokens if left blank. +# --- DATABASES --- +# Passwords and connection URIs are auto-generated in .env.secrets. +# Edit host/port/user/dbname here if needed. # MongoDB (Application Data) -MONGO_URI= MONGO_HOST=mongodb MONGO_PORT=27017 MONGO_USER=alfred -MONGO_PASSWORD= -MONGO_DB_NAME=LibreChat +MONGO_DB_NAME=alfred # PostgreSQL (Vector Database / RAG) -POSTGRES_URI= POSTGRES_HOST=vectordb POSTGRES_PORT=5432 POSTGRES_USER=alfred -POSTGRES_PASSWORD= POSTGRES_DB_NAME=alfred # --- EXTERNAL SERVICES --- -# Media Metadata (Required) -# Get your key at https://www.themoviedb.org/ -TMDB_API_KEY= + +# TMDB — Media metadata (required). Get your key at https://www.themoviedb.org/ +# → TMDB_API_KEY goes in .env.secrets TMDB_BASE_URL=https://api.themoviedb.org/3 -# qBittorrent integration +# qBittorrent +# → QBITTORRENT_PASSWORD goes in .env.secrets QBITTORRENT_URL=http://qbittorrent:16140 QBITTORRENT_USERNAME=admin -QBITTORRENT_PASSWORD= QBITTORRENT_PORT=16140 # Meilisearch -MEILI_ENABLED=FALSE -MEILI_NO_ANALYTICS=TRUE +# → MEILI_MASTER_KEY goes in .env.secrets +# MEILI_ENABLED=false # KEY DOESN'T EXISTS => SEARCH IS THE PROPER KEY +SEARCH=false +MEILI_NO_ANALYTICS=true MEILI_HOST=http://meilisearch:7700 -MEILI_MASTER_KEY= # --- LLM CONFIGURATION --- -# Providers: 'local', 'openai', 'anthropic', 'deepseek', 'google', 'kimi' +# Providers: local, openai, anthropic, deepseek, google, kimi +# → API keys go in .env.secrets DEFAULT_LLM_PROVIDER=local # Local LLM (Ollama) -OLLAMA_BASE_URL=http://ollama:11434 -OLLAMA_MODEL=llama3.3:latest +#OLLAMA_BASE_URL=http://ollama:11434 +#OLLAMA_MODEL=llama3.3:latest -# --- API KEYS (OPTIONAL) --- -# Fill only the ones you intend to use. -ANTHROPIC_API_KEY= -DEEPSEEK_API_KEY= -GOOGLE_API_KEY= -KIMI_API_KEY= -OPENAI_API_KEY= +OLLAMA_BASE_URL=http://10.0.0.11:11434 +OLLAMA_MODEL=glm-4.7-flash:latest # --- RAG ENGINE --- -# Enable/Disable the Retrieval Augmented Generation system RAG_ENABLED=TRUE RAG_API_URL=http://rag_api:8000 RAG_API_PORT=8000 diff --git a/.env.example b/.env.example index c82f6d9..a675474 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,13 @@ +# --- IMPORTANT --- +# Settings are split across multiple files for clarity. +# Files (loaded in this order, last wins): +# .env.alfred — app config and service addresses (safe to commit) +# .env.secrets — generated secrets, passwords, URIs and API keys (DO NOT COMMIT) +# .env.make — build metadata synced from pyproject.toml (safe to commit) +# +# To customize: edit .env.alfred for config, .env.secrets for secrets. + +# --- Alfred --- MAX_HISTORY_MESSAGES=10 MAX_TOOL_ITERATIONS=10 REQUEST_TIMEOUT=30 @@ -8,84 +18,54 @@ LLM_TEMPERATURE=0.2 # Persistence DATA_STORAGE_DIR=data -# Network configuration +# Network HOST=0.0.0.0 PORT=3080 -# Build informations (Synced with pyproject.toml via bootstrap) -ALFRED_VERSION= -IMAGE_NAME= -LIBRECHAT_VERSION= -PYTHON_VERSION= -PYTHON_VERSION_SHORT= -RAG_VERSION= -RUNNER= -SERVICE_NAME= - -# --- SECURITY KEYS (CRITICAL) --- -# These are used for session tokens and encrypting sensitive data in MongoDB. -# If you lose these, you lose access to encrypted stored credentials. -JWT_SECRET= -JWT_REFRESH_SECRET= -CREDS_KEY= -CREDS_IV= - -# --- DATABASES (AUTO-SECURED) --- -# Alfred uses MongoDB for application state and PostgreSQL for Vector RAG. -# Passwords will be generated as 24-character secure tokens if left blank. +# --- DATABASES --- +# Passwords and connection URIs are auto-generated in .env.secrets. +# Edit host/port/user/dbname here if needed. # MongoDB (Application Data) -MONGO_URI= MONGO_HOST=mongodb MONGO_PORT=27017 MONGO_USER=alfred -MONGO_PASSWORD= MONGO_DB_NAME=LibreChat # PostgreSQL (Vector Database / RAG) -POSTGRES_URI= POSTGRES_HOST=vectordb POSTGRES_PORT=5432 POSTGRES_USER=alfred -POSTGRES_PASSWORD= POSTGRES_DB_NAME=alfred # --- EXTERNAL SERVICES --- -# Media Metadata (Required) -# Get your key at https://www.themoviedb.org/ -TMDB_API_KEY= + +# TMDB — Media metadata (required). Get your key at https://www.themoviedb.org/ +# → TMDB_API_KEY goes in .env.secrets TMDB_BASE_URL=https://api.themoviedb.org/3 -# qBittorrent integration +# qBittorrent +# → QBITTORRENT_PASSWORD goes in .env.secrets QBITTORRENT_URL=http://qbittorrent:16140 QBITTORRENT_USERNAME=admin -QBITTORRENT_PASSWORD= QBITTORRENT_PORT=16140 # Meilisearch +# → MEILI_MASTER_KEY goes in .env.secrets MEILI_ENABLED=FALSE MEILI_NO_ANALYTICS=TRUE MEILI_HOST=http://meilisearch:7700 -MEILI_MASTER_KEY= # --- LLM CONFIGURATION --- -# Providers: 'local', 'openai', 'anthropic', 'deepseek', 'google', 'kimi' +# Providers: local, openai, anthropic, deepseek, google, kimi +# → API keys go in .env.secrets DEFAULT_LLM_PROVIDER=local # Local LLM (Ollama) OLLAMA_BASE_URL=http://ollama:11434 OLLAMA_MODEL=llama3.3:latest -# --- API KEYS (OPTIONAL) --- -# Fill only the ones you intend to use. -ANTHROPIC_API_KEY= -DEEPSEEK_API_KEY= -GOOGLE_API_KEY= -KIMI_API_KEY= -OPENAI_API_KEY= - # --- RAG ENGINE --- -# Enable/Disable the Retrieval Augmented Generation system RAG_ENABLED=TRUE RAG_API_URL=http://rag_api:8000 RAG_API_PORT=8000 diff --git a/.env.make b/.env.make index 373d53a..b2799f3 100644 --- a/.env.make +++ b/.env.make @@ -1,9 +1,8 @@ # Auto-generated from pyproject.toml — do not edit manually -export ALFRED_VERSION=0.1.7 -export PYTHON_VERSION=3.14.3 -export PYTHON_VERSION_SHORT=3.14 -export IMAGE_NAME=alfred_media_organizer -export SERVICE_NAME=alfred -export LIBRECHAT_VERSION=v0.8.4 -export RAG_VERSION=v0.7.3 -export UV_VERSION=0.11.6 +ALFRED_VERSION=0.1.7 +PYTHON_VERSION=3.14.3 +IMAGE_NAME=alfred_media_organizer +SERVICE_NAME=alfred +LIBRECHAT_VERSION=v0.8.4 +RAG_VERSION=v0.7.3 +UV_VERSION=0.11.6 diff --git a/.gitignore b/.gitignore index 69d7317..05fe502 100644 --- a/.gitignore +++ b/.gitignore @@ -55,7 +55,7 @@ coverage.xml Thumbs.db # Secrets -.env +.env.secrets # Backup files *.backup @@ -65,3 +65,11 @@ data/* # Application logs logs/* + +# Documentation folder +docs/ + +# .md files +*.md + +# diff --git a/Dockerfile b/Dockerfile index aace701..267db7d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,38 +2,36 @@ # check=skip=InvalidDefaultArgInFrom ARG PYTHON_VERSION -ARG PYTHON_VERSION_SHORT ARG UV_VERSION +# Stage 0: uv binary (workaround — --from doesn't support ARG expansion) +FROM ghcr.io/astral-sh/uv:${UV_VERSION} AS uv-bin + # =========================================== # Stage 1: Builder # =========================================== FROM python:${PYTHON_VERSION}-slim-bookworm AS builder -# Re-declare ARGs after FROM to make them available in this stage -ARG UV_VERSION - -# STFU - No need - Write logs asap ENV DEBIAN_FRONTEND=noninteractive \ PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 + PYTHONUNBUFFERED=1 \ + UV_PROJECT_ENVIRONMENT=/venv -# Install build dependencies (needs root) -RUN apt-get update \ - && apt-get install -y --no-install-recommends build-essential \ - && rm -rf /var/lib/apt/lists/* +# Install build dependencies +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + apt-get update \ + && apt-get install -y --no-install-recommends build-essential -# Install uv globally (needs root) - Save cache for future -COPY --from=ghcr.io/astral-sh/uv:${UV_VERSION} /uv /usr/local/bin/uv +# Install uv globally +COPY --from=uv-bin /uv /usr/local/bin/uv -# Set working directory for dependency installation WORKDIR /tmp -# Copy dependency files COPY pyproject.toml uv.lock Makefile ./ -# Install dependencies as root (to avoid permission issues with system packages) -RUN --mount=type=cache,target=/root/.cache/uv uv sync --system +# Install dependencies into /venv +RUN --mount=type=cache,target=/root/.cache/uv uv sync COPY scripts/ ./scripts/ COPY .env.example ./ @@ -43,7 +41,7 @@ COPY .env.example ./ # =========================================== FROM builder AS test -RUN --mount=type=cache,target=/root/.cache/uv uv sync --system -e .[dev] +RUN --mount=type=cache,target=/root/.cache/uv uv sync --group dev COPY alfred/ ./alfred COPY scripts ./scripts @@ -54,52 +52,40 @@ COPY tests/ ./tests # =========================================== FROM python:${PYTHON_VERSION}-slim-bookworm AS runtime -ARG PYTHON_VERSION_SHORT - -# TODO: A-t-on encore besoin de toutes les clés ? -ENV LLM_PROVIDER=deepseek \ - MEMORY_STORAGE_DIR=/data/memory \ - PYTHONDONTWRITEBYTECODE=1 \ +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ PYTHONPATH=/home/appuser \ - PYTHONUNBUFFERED=1 + PATH="/venv/bin:$PATH" -# Install runtime dependencies (needs root) -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates \ - && rm -rf /var/lib/apt/lists/* \ - && apt-get clean +# Install runtime dependencies +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates # Create non-root user RUN useradd -m -u 1000 -s /bin/bash appuser -# Create data directories (needs root for /data) +# Create data directories RUN mkdir -p /data /logs \ && chown -R appuser:appuser /data /logs -# Switch to non-root user USER appuser - -# Set working directory (owned by appuser) WORKDIR /home/appuser -# Copy Python packages from builder stage -COPY --from=builder /usr/local/lib/python${PYTHON_VERSION_SHORT}/site-packages /usr/local/lib/python${PYTHON_VERSION_SHORT}/site-packages -COPY --from=builder /usr/local/bin /usr/local/bin +# Copy venv from builder stage +COPY --from=builder /venv /venv -# Copy application code (already owned by appuser) +# Copy application code COPY --chown=appuser:appuser alfred/ ./alfred COPY --chown=appuser:appuser scripts/ ./scripts COPY --chown=appuser:appuser .env.example ./ COPY --chown=appuser:appuser pyproject.toml ./ -# Create volumes for persistent data VOLUME ["/data", "/logs"] - -# Expose port EXPOSE 8000 -# Health check HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD python -c "import requests; requests.get('http://localhost:8000/health', timeout=5).raise_for_status()" || exit 1 -CMD ["python", "-m", "uvicorn", "alfred.app:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file +CMD ["python", "-m", "uvicorn", "alfred.app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Makefile b/Makefile index 27e7df4..6a81ab0 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ .DEFAULT_GOAL := help # --- Load Config from pyproject.toml --- +export -include .env.make # --- Profiles management --- @@ -9,10 +10,12 @@ p ?= full PROFILES_PARAM := COMPOSE_PROFILES=$(p) # --- Commands --- -DOCKER_COMPOSE := docker compose -DOCKER_BUILD := docker build --no-cache \ +DOCKER_COMPOSE := docker compose \ + --env-file .env.alfred \ + --env-file .env.secrets \ + --env-file .env.make +DOCKER_BUILD := DOCKER_BUILDKIT=1 docker build \ --build-arg PYTHON_VERSION=$(PYTHON_VERSION) \ - --build-arg PYTHON_VERSION_SHORT=$(PYTHON_VERSION_SHORT) \ --build-arg UV_VERSION=$(UV_VERSION) # --- Phony --- @@ -74,7 +77,7 @@ build-test: .env.make # --- Dependencies --- install: - @echo "Installing dependencies with $(RUNNER)..." + @echo "Installing dependencies with uv..." @uv install \ && echo "✓ Dependencies installed" \ || (echo "✗ Installation failed" && exit 1) @@ -86,7 +89,7 @@ install-hooks: || (echo "✗ Hook installation failed" && exit 1) update: - @echo "Updating dependencies with $(RUNNER)..." + @echo "Updating dependencies with uv..." @uv update \ && echo "✓ Dependencies updated" \ || (echo "✗ Update failed" && exit 1) @@ -112,7 +115,7 @@ lint: format: @echo "Formatting code..." - @uv run ruff format . && $(RUNNER) run ruff check --fix . \ + @uv run ruff format . && uv run ruff check --fix . \ && echo "✓ Code formatted" \ || (echo "✗ Formatting failed" && exit 1) @@ -138,8 +141,7 @@ major minor patch: _check-main _ci-dump-config: @echo "image_name=$(IMAGE_NAME)" @echo "python_version=$(PYTHON_VERSION)" - @echo "python_version_short=$(PYTHON_VERSION_SHORT)" - @echo "runner=$(RUNNER)" + @echo "uv_version=$(UV_VERSION)" @echo "service_name=$(SERVICE_NAME)" _ci-run-tests:build-test @@ -176,7 +178,7 @@ help: @echo "" @echo "Dev & Quality:" @echo " setup Bootstrap .env and security keys" - @echo " install Install dependencies via $(RUNNER)" + @echo " install Install dependencies via uv" @echo " test Run pytest suite" @echo " coverage Run tests and generate HTML report" @echo " lint/format Quality and style checks" diff --git a/alfred/app.py b/alfred/app.py index 2a1c996..dae2d7e 100644 --- a/alfred/app.py +++ b/alfred/app.py @@ -29,7 +29,7 @@ app = FastAPI( version="0.2.0", ) -memory_path = Path(settings.data_storage) / "memory" +memory_path = Path(settings.data_storage_dir) / "memory" init_memory(storage_dir=str(memory_path)) logger.info(f"Memory context initialized (path: {memory_path})") diff --git a/alfred/settings.py b/alfred/settings.py index da4b2b3..d326698 100644 --- a/alfred/settings.py +++ b/alfred/settings.py @@ -1,14 +1,14 @@ """ Application settings — Alfred only. -Loaded from .env.alfred and .env.secrets (via docker-compose env_file). -At runtime, all variables are already in the environment — pydantic-settings -picks them up automatically. +Only declares what Alfred's Python code actually consumes. +Everything else (.env.alfred, .env.secrets) is loaded by Docker Compose +for other services and ignored here via extra="ignore". """ from pathlib import Path -from pydantic import Field, computed_field, field_validator +from pydantic import field_validator from pydantic_settings import BaseSettings, SettingsConfigDict BASE_DIR = Path(__file__).resolve().parent.parent @@ -20,86 +20,38 @@ class ConfigurationError(Exception): class Settings(BaseSettings): model_config = SettingsConfigDict( - env_file=[BASE_DIR / ".env.alfred", BASE_DIR / ".env.secrets"], + env_file=[BASE_DIR / ".env.alfred", BASE_DIR / ".env.secrets", BASE_DIR / ".env.make"], env_file_encoding="utf-8", extra="ignore", case_sensitive=False, ) # --- APP --- - host: str = "0.0.0.0" - port: int = 3080 max_history_messages: int = 10 max_tool_iterations: int = 10 request_timeout: int = 30 llm_temperature: float = 0.2 data_storage_dir: str = "data" - # --- DATABASE --- - mongo_host: str = "mongodb" - mongo_port: int = 27017 - mongo_user: str = "alfred" - mongo_password: str = Field(repr=False) - mongo_db_name: str = "LibreChat" - - @computed_field(repr=False) - @property - def mongo_uri(self) -> str: - return ( - f"mongodb://{self.mongo_user}:{self.mongo_password}" - f"@{self.mongo_host}:{self.mongo_port}/{self.mongo_db_name}" - f"?authSource=admin" - ) - - postgres_host: str = "vectordb" - postgres_port: int = 5432 - postgres_user: str = "alfred" - postgres_password: str = Field(repr=False) - postgres_db_name: str = "alfred" - - @computed_field(repr=False) - @property - def postgres_uri(self) -> str: - return ( - f"postgresql://{self.postgres_user}:{self.postgres_password}" - f"@{self.postgres_host}:{self.postgres_port}/{self.postgres_db_name}" - ) + # --- BUILD --- + alfred_version: str | None = None # --- LLM --- default_llm_provider: str = "local" ollama_base_url: str = "http://ollama:11434" - # Models: ... ollama_model: str = "llama3.3:latest" deepseek_base_url: str = "https://api.deepseek.com" deepseek_model: str = "deepseek-chat" # --- API KEYS --- tmdb_api_key: str | None = None + tmdb_base_url: str = "https://api.themoviedb.org/3" deepseek_api_key: str | None = None openai_api_key: str | None = None anthropic_api_key: str | None = None google_api_key: str | None = None kimi_api_key: str | None = None - # --- EXTERNAL SERVICES --- - tmdb_base_url: str = "https://api.themoviedb.org/3" - qbittorrent_url: str = "http://qbittorrent:16140" - qbittorrent_username: str = "admin" - qbittorrent_password: str = Field(repr=False) - - # --- RAG --- - rag_enabled: bool = True - rag_api_url: str = "http://rag_api:8000" - embeddings_provider: str = "ollama" - # Models: ... - embeddings_model: str = "nomic-embed-text" - - # --- MEILISEARCH --- - meili_enabled: bool = False - meili_no_analytics: bool = True - meili_host: str = "http://meilisearch:7700" - meili_master_key: str = Field(repr=False) - # --- VALIDATORS --- @field_validator("llm_temperature") @classmethod @@ -129,5 +81,17 @@ class Settings(BaseSettings): def is_deepseek_configured(self) -> bool: return bool(self.deepseek_api_key) + def is_openai_configured(self) -> bool: + return bool(self.openai_api_key) + + def is_anthropic_configured(self) -> bool: + return bool(self.anthropic_api_key) + + def is_google_configured(self) -> bool: + return bool(self.google_api_key) + + def is_kimi_configured(self) -> bool: + return bool(self.kimi_api_key) + settings = Settings() diff --git a/docker-compose.yaml b/docker-compose.yaml index 5612d9a..c5ab629 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -8,7 +8,6 @@ services: target: builder args: PYTHON_VERSION: ${PYTHON_VERSION} - PYTHON_VERSION_SHORT: ${PYTHON_VERSION_SHORT} UV_VERSION: ${UV_VERSION} command: python scripts/bootstrap.py networks: @@ -22,7 +21,6 @@ services: context: . args: PYTHON_VERSION: ${PYTHON_VERSION} - PYTHON_VERSION_SHORT: ${PYTHON_VERSION_SHORT} UV_VERSION: ${UV_VERSION} depends_on: alfred-init: @@ -33,13 +31,15 @@ services: required: true - path: .env.secrets required: true + - path: .env.make + required: true volumes: - ./data:/data - ./logs:/logs # TODO: Hot reload (comment out in production) - #- ./alfred:/home/appuser/alfred + - ./alfred:/home/appuser/alfred command: > - sh -c "python -u -m uvicorn alfred.app:app --host 0.0.0.0 --port 8000 2>&1 | tee -a /logs/alfred.log" + sh -c "python -u -m uvicorn alfred.app:app --host 0.0.0.0 --port 8000 --reload 2>&1 | tee -a /logs/alfred.log" networks: - alfred-net @@ -89,9 +89,10 @@ services: - path: .env.secrets required: true environment: - # Remap value name - MONGO_INITDB_ROOT_USERNAME=${MONGO_USER} - MONGO_INITDB_ROOT_PASSWORD=${MONGO_PASSWORD} + # Fix MongoDB + Linux kernel >= 6.19 + - GLIBC_TUNABLES=glibc.cpu.hwcaps=-SHSTK ports: - "${MONGO_PORT}:${MONGO_PORT}" volumes: @@ -99,7 +100,7 @@ services: - ./mongod.conf:/etc/mongod.conf:ro command: ["mongod", "--config", "/etc/mongod.conf"] healthcheck: - test: mongosh --quiet -u "${MONGO_USER}" -p "${MONGO_PASSWORD}" --authenticationDatabase admin --eval "db.adminCommand('ping')" + test: bash -c "echo > /dev/tcp/localhost/27017" interval: 10s timeout: 5s retries: 5 diff --git a/librechat/.env.example b/librechat/.env.example new file mode 100644 index 0000000..db09bb4 --- /dev/null +++ b/librechat/.env.example @@ -0,0 +1,878 @@ +#=====================================================================# +# LibreChat Configuration # +#=====================================================================# +# Please refer to the reference documentation for assistance # +# with configuring your LibreChat environment. # +# # +# https://www.librechat.ai/docs/configuration/dotenv # +#=====================================================================# + +#==================================================# +# Server Configuration # +#==================================================# + +HOST=localhost +PORT=3080 + +MONGO_URI=mongodb://127.0.0.1:27017/LibreChat +#The maximum number of connections in the connection pool. */ +MONGO_MAX_POOL_SIZE= +#The minimum number of connections in the connection pool. */ +MONGO_MIN_POOL_SIZE= +#The maximum number of connections that may be in the process of being established concurrently by the connection pool. */ +MONGO_MAX_CONNECTING= +#The maximum number of milliseconds that a connection can remain idle in the pool before being removed and closed. */ +MONGO_MAX_IDLE_TIME_MS= +#The maximum time in milliseconds that a thread can wait for a connection to become available. */ +MONGO_WAIT_QUEUE_TIMEOUT_MS= +# Set to false to disable automatic index creation for all models associated with this connection. */ +MONGO_AUTO_INDEX= +# Set to `false` to disable Mongoose automatically calling `createCollection()` on every model created on this connection. */ +MONGO_AUTO_CREATE= + +DOMAIN_CLIENT=http://localhost:3080 +DOMAIN_SERVER=http://localhost:3080 + +NO_INDEX=true +# Use the address that is at most n number of hops away from the Express application. +# req.socket.remoteAddress is the first hop, and the rest are looked for in the X-Forwarded-For header from right to left. +# A value of 0 means that the first untrusted address would be req.socket.remoteAddress, i.e. there is no reverse proxy. +# Defaulted to 1. +TRUST_PROXY=1 + +# Minimum password length for user authentication +# Default: 8 +# Note: When using LDAP authentication, you may want to set this to 1 +# to bypass local password validation, as LDAP servers handle their own +# password policies. +# MIN_PASSWORD_LENGTH=8 + +# When enabled, the app will continue running after encountering uncaught exceptions +# instead of exiting the process. Not recommended for production unless necessary. +# CONTINUE_ON_UNCAUGHT_EXCEPTION=false + +#===============# +# JSON Logging # +#===============# + +# Use when process console logs in cloud deployment like GCP/AWS +CONSOLE_JSON=false + +#===============# +# Debug Logging # +#===============# + +DEBUG_LOGGING=true +DEBUG_CONSOLE=false +# Set to true to enable agent debug logging +AGENT_DEBUG_LOGGING=false + +# Enable memory diagnostics (logs heap/RSS snapshots every 60s, auto-enabled with --inspect) +# MEM_DIAG=true + +#=============# +# Permissions # +#=============# + +# UID=1000 +# GID=1000 + +#==============# +# Node Options # +#==============# + +# NOTE: NODE_MAX_OLD_SPACE_SIZE is NOT recognized by Node.js directly. +# This variable is used as a build argument for Docker or CI/CD workflows, +# and is NOT used by Node.js to set the heap size at runtime. +# To configure Node.js memory, use NODE_OPTIONS, e.g.: +# NODE_OPTIONS="--max-old-space-size=6144" +# See: https://nodejs.org/api/cli.html#--max-old-space-sizesize-in-mib +NODE_MAX_OLD_SPACE_SIZE=6144 + +#===============# +# Configuration # +#===============# +# Use an absolute path, a relative path, or a URL + +# CONFIG_PATH="/alternative/path/to/librechat.yaml" + +#==================# +# Langfuse Tracing # +#==================# + +# Get Langfuse API keys for your project from the project settings page: https://cloud.langfuse.com + +# LANGFUSE_PUBLIC_KEY= +# LANGFUSE_SECRET_KEY= +# LANGFUSE_BASE_URL= + +#===================================================# +# Endpoints # +#===================================================# + +# ENDPOINTS=openAI,assistants,azureOpenAI,google,anthropic + +PROXY= + +#===================================# +# Known Endpoints - librechat.yaml # +#===================================# +# https://www.librechat.ai/docs/configuration/librechat_yaml/ai_endpoints + +# ANYSCALE_API_KEY= +# APIPIE_API_KEY= +# COHERE_API_KEY= +# DEEPSEEK_API_KEY= +# DATABRICKS_API_KEY= +# FIREWORKS_API_KEY= +# GROQ_API_KEY= +# HUGGINGFACE_TOKEN= +# MISTRAL_API_KEY= +# OPENROUTER_KEY= +# PERPLEXITY_API_KEY= +# SHUTTLEAI_API_KEY= +# TOGETHERAI_API_KEY= +# UNIFY_API_KEY= +# XAI_API_KEY= + +#============# +# Anthropic # +#============# + +ANTHROPIC_API_KEY=user_provided +# ANTHROPIC_MODELS=claude-sonnet-4-6,claude-opus-4-6,claude-opus-4-20250514,claude-sonnet-4-20250514,claude-3-7-sonnet-20250219,claude-3-5-sonnet-20241022,claude-3-5-haiku-20241022,claude-3-opus-20240229,claude-3-sonnet-20240229,claude-3-haiku-20240307 +# ANTHROPIC_REVERSE_PROXY= + +# Set to true to use Anthropic models through Google Vertex AI instead of direct API +# ANTHROPIC_USE_VERTEX= +# ANTHROPIC_VERTEX_REGION=us-east5 + +#============# +# Azure # +#============# + +# Note: these variables are DEPRECATED +# Use the `librechat.yaml` configuration for `azureOpenAI` instead +# You may also continue to use them if you opt out of using the `librechat.yaml` configuration + +# AZURE_OPENAI_DEFAULT_MODEL=gpt-3.5-turbo # Deprecated +# AZURE_OPENAI_MODELS=gpt-3.5-turbo,gpt-4 # Deprecated +# AZURE_USE_MODEL_AS_DEPLOYMENT_NAME=TRUE # Deprecated +# AZURE_API_KEY= # Deprecated +# AZURE_OPENAI_API_INSTANCE_NAME= # Deprecated +# AZURE_OPENAI_API_DEPLOYMENT_NAME= # Deprecated +# AZURE_OPENAI_API_VERSION= # Deprecated +# AZURE_OPENAI_API_COMPLETIONS_DEPLOYMENT_NAME= # Deprecated +# AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME= # Deprecated + +#=================# +# AWS Bedrock # +#=================# + +# BEDROCK_AWS_DEFAULT_REGION=us-east-1 # A default region must be provided +# BEDROCK_AWS_ACCESS_KEY_ID=someAccessKey +# BEDROCK_AWS_SECRET_ACCESS_KEY=someSecretAccessKey +# BEDROCK_AWS_SESSION_TOKEN=someSessionToken + +# Note: This example list is not meant to be exhaustive. If omitted, all known, supported model IDs will be included for you. +# BEDROCK_AWS_MODELS=anthropic.claude-sonnet-4-6,anthropic.claude-opus-4-6-v1,anthropic.claude-3-5-sonnet-20240620-v1:0,meta.llama3-1-8b-instruct-v1:0 +# Cross-region inference model IDs: us.anthropic.claude-sonnet-4-6,us.anthropic.claude-opus-4-6-v1,global.anthropic.claude-opus-4-6-v1 + +# See all Bedrock model IDs here: https://docs.aws.amazon.com/bedrock/latest/userguide/model-ids.html#model-ids-arns + +# Notes on specific models: +# The following models are not support due to not supporting streaming: +# ai21.j2-mid-v1 + +# The following models are not support due to not supporting conversation history: +# ai21.j2-ultra-v1, cohere.command-text-v14, cohere.command-light-text-v14 + +#============# +# Google # +#============# + +GOOGLE_KEY=user_provided + +# GOOGLE_REVERSE_PROXY= +# Some reverse proxies do not support the X-goog-api-key header, uncomment to pass the API key in Authorization header instead. +# GOOGLE_AUTH_HEADER=true + +# Gemini API (AI Studio) +# GOOGLE_MODELS=gemini-3.1-pro-preview,gemini-3.1-pro-preview-customtools,gemini-3.1-flash-lite-preview,gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite,gemini-2.0-flash,gemini-2.0-flash-lite + +# Vertex AI +# GOOGLE_MODELS=gemini-3.1-pro-preview,gemini-3.1-pro-preview-customtools,gemini-3.1-flash-lite-preview,gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite,gemini-2.0-flash-001,gemini-2.0-flash-lite-001 + +# GOOGLE_TITLE_MODEL=gemini-2.0-flash-lite-001 + +# Google Cloud region for Vertex AI (used by both chat and image generation) +# GOOGLE_LOC=us-central1 + +# Alternative region env var for Gemini Image Generation +# GOOGLE_CLOUD_LOCATION=global + +# Vertex AI Service Account Configuration +# Path to your Google Cloud service account JSON file +# GOOGLE_SERVICE_KEY_FILE=/path/to/service-account.json + +# Google Safety Settings +# NOTE: These settings apply to both Vertex AI and Gemini API (AI Studio) +# +# For Vertex AI: +# To use the BLOCK_NONE setting, you need either: +# (a) Access through an allowlist via your Google account team, or +# (b) Switch to monthly invoiced billing: https://cloud.google.com/billing/docs/how-to/invoiced-billing +# +# For Gemini API (AI Studio): +# BLOCK_NONE is available by default, no special account requirements. +# +# Available options: BLOCK_NONE, BLOCK_ONLY_HIGH, BLOCK_MEDIUM_AND_ABOVE, BLOCK_LOW_AND_ABOVE +# +# GOOGLE_SAFETY_SEXUALLY_EXPLICIT=BLOCK_ONLY_HIGH +# GOOGLE_SAFETY_HATE_SPEECH=BLOCK_ONLY_HIGH +# GOOGLE_SAFETY_HARASSMENT=BLOCK_ONLY_HIGH +# GOOGLE_SAFETY_DANGEROUS_CONTENT=BLOCK_ONLY_HIGH +# GOOGLE_SAFETY_CIVIC_INTEGRITY=BLOCK_ONLY_HIGH + +#========================# +# Gemini Image Generation # +#========================# + +# Gemini Image Generation Tool (for Agents) +# Supports multiple authentication methods in priority order: +# 1. User-provided API key (via GUI) +# 2. GEMINI_API_KEY env var (admin-configured) +# 3. GOOGLE_KEY env var (shared with Google chat endpoint) +# 4. Vertex AI service account (via GOOGLE_SERVICE_KEY_FILE) + +# Option A: Use dedicated Gemini API key for image generation +# GEMINI_API_KEY=your-gemini-api-key + +# Vertex AI model for image generation (defaults to gemini-2.5-flash-image) +# GEMINI_IMAGE_MODEL=gemini-2.5-flash-image + +#============# +# OpenAI # +#============# + +OPENAI_API_KEY=user_provided +# OPENAI_MODELS=gpt-5,gpt-5-codex,gpt-5-mini,gpt-5-nano,o3-pro,o3,o4-mini,gpt-4.1,gpt-4.1-mini,gpt-4.1-nano,o3-mini,o1-pro,o1,gpt-4o,gpt-4o-mini + +DEBUG_OPENAI=false + +# TITLE_CONVO=false +# OPENAI_TITLE_MODEL=gpt-4o-mini + +# OPENAI_SUMMARIZE=true +# OPENAI_SUMMARY_MODEL=gpt-4o-mini + +# OPENAI_FORCE_PROMPT=true + +# OPENAI_REVERSE_PROXY= + +# OPENAI_ORGANIZATION= + +#====================# +# Assistants API # +#====================# + +ASSISTANTS_API_KEY=user_provided +# ASSISTANTS_BASE_URL= +# ASSISTANTS_MODELS=gpt-4o,gpt-4o-mini,gpt-3.5-turbo-0125,gpt-3.5-turbo-16k-0613,gpt-3.5-turbo-16k,gpt-3.5-turbo,gpt-4,gpt-4-0314,gpt-4-32k-0314,gpt-4-0613,gpt-3.5-turbo-0613,gpt-3.5-turbo-1106,gpt-4-0125-preview,gpt-4-turbo-preview,gpt-4-1106-preview + +#==========================# +# Azure Assistants API # +#==========================# + +# Note: You should map your credentials with custom variables according to your Azure OpenAI Configuration +# The models for Azure Assistants are also determined by your Azure OpenAI configuration. + +# More info, including how to enable use of Assistants with Azure here: +# https://www.librechat.ai/docs/configuration/librechat_yaml/ai_endpoints/azure#using-assistants-with-azure + +CREDS_KEY=f34be427ebb29de8d88c107a71546019685ed8b241d8f2ed00c3df97ad2566f0 +CREDS_IV=e2341419ec3dd3d19b13a1a87fafcbfb + +# Azure AI Search +#----------------- +AZURE_AI_SEARCH_SERVICE_ENDPOINT= +AZURE_AI_SEARCH_INDEX_NAME= +AZURE_AI_SEARCH_API_KEY= + +AZURE_AI_SEARCH_API_VERSION= +AZURE_AI_SEARCH_SEARCH_OPTION_QUERY_TYPE= +AZURE_AI_SEARCH_SEARCH_OPTION_TOP= +AZURE_AI_SEARCH_SEARCH_OPTION_SELECT= + +# OpenAI Image Tools Customization +#---------------- +# IMAGE_GEN_OAI_API_KEY= # Create or reuse OpenAI API key for image generation tool +# IMAGE_GEN_OAI_BASEURL= # Custom OpenAI base URL for image generation tool +# IMAGE_GEN_OAI_AZURE_API_VERSION= # Custom Azure OpenAI deployments +# IMAGE_GEN_OAI_MODEL=gpt-image-1 # OpenAI image model (e.g., gpt-image-1, gpt-image-1.5) +# IMAGE_GEN_OAI_DESCRIPTION= +# IMAGE_GEN_OAI_DESCRIPTION_WITH_FILES=Custom description for image generation tool when files are present +# IMAGE_GEN_OAI_DESCRIPTION_NO_FILES=Custom description for image generation tool when no files are present +# IMAGE_EDIT_OAI_DESCRIPTION=Custom description for image editing tool +# IMAGE_GEN_OAI_PROMPT_DESCRIPTION=Custom prompt description for image generation tool +# IMAGE_EDIT_OAI_PROMPT_DESCRIPTION=Custom prompt description for image editing tool + +# DALL·E +#---------------- +# DALLE_API_KEY= +# DALLE3_API_KEY= +# DALLE2_API_KEY= +# DALLE3_SYSTEM_PROMPT= +# DALLE2_SYSTEM_PROMPT= +# DALLE_REVERSE_PROXY= +# DALLE3_BASEURL= +# DALLE2_BASEURL= + +# DALL·E (via Azure OpenAI) +# Note: requires some of the variables above to be set +#---------------- +# DALLE3_AZURE_API_VERSION= +# DALLE2_AZURE_API_VERSION= + +# Flux +#----------------- +FLUX_API_BASE_URL=https://api.us1.bfl.ai +# FLUX_API_BASE_URL = 'https://api.bfl.ml'; + +# Get your API key at https://api.us1.bfl.ai/auth/profile +# FLUX_API_KEY= + +# Google +#----------------- +GOOGLE_SEARCH_API_KEY= +GOOGLE_CSE_ID= + +# Stable Diffusion +#----------------- +SD_WEBUI_URL=http://host.docker.internal:7860 + +# Tavily +#----------------- +TAVILY_API_KEY= + +# Traversaal +#----------------- +TRAVERSAAL_API_KEY= + +# WolframAlpha +#----------------- +WOLFRAM_APP_ID= + +# Zapier +#----------------- +ZAPIER_NLA_API_KEY= + +#==================================================# +# Search # +#==================================================# + +SEARCH=true +MEILI_NO_ANALYTICS=true +MEILI_HOST=http://0.0.0.0:7700 +MEILI_MASTER_KEY=DrhYf7zENyR6AlUCKmnz0eYASOQdl6zxH7s7MKFSfFCt + +# Optional: Disable indexing, useful in a multi-node setup +# where only one instance should perform an index sync. +# MEILI_NO_SYNC=true + +#==================================================# +# Speech to Text & Text to Speech # +#==================================================# + +STT_API_KEY= +TTS_API_KEY= + +#==================================================# +# RAG # +#==================================================# +# More info: https://www.librechat.ai/docs/configuration/rag_api + +# RAG_OPENAI_BASEURL= +# RAG_OPENAI_API_KEY= +# RAG_USE_FULL_CONTEXT= +# EMBEDDINGS_PROVIDER=openai +# EMBEDDINGS_MODEL=text-embedding-3-small + +#===================================================# +# User System # +#===================================================# + +#========================# +# Moderation # +#========================# + +OPENAI_MODERATION=false +OPENAI_MODERATION_API_KEY= +# OPENAI_MODERATION_REVERSE_PROXY= + +BAN_VIOLATIONS=true +BAN_DURATION=1000 * 60 * 60 * 2 +BAN_INTERVAL=20 + +LOGIN_VIOLATION_SCORE=1 +REGISTRATION_VIOLATION_SCORE=1 +CONCURRENT_VIOLATION_SCORE=1 +MESSAGE_VIOLATION_SCORE=1 +NON_BROWSER_VIOLATION_SCORE=20 +TTS_VIOLATION_SCORE=0 +STT_VIOLATION_SCORE=0 +FORK_VIOLATION_SCORE=0 +IMPORT_VIOLATION_SCORE=0 +FILE_UPLOAD_VIOLATION_SCORE=0 + +LOGIN_MAX=7 +LOGIN_WINDOW=5 +REGISTER_MAX=5 +REGISTER_WINDOW=60 + +LIMIT_CONCURRENT_MESSAGES=true +CONCURRENT_MESSAGE_MAX=2 + +LIMIT_MESSAGE_IP=true +MESSAGE_IP_MAX=40 +MESSAGE_IP_WINDOW=1 + +LIMIT_MESSAGE_USER=false +MESSAGE_USER_MAX=40 +MESSAGE_USER_WINDOW=1 + +ILLEGAL_MODEL_REQ_SCORE=5 + +#========================# +# Balance # +#========================# + +# CHECK_BALANCE=false +# START_BALANCE=20000 # note: the number of tokens that will be credited after registration. + +#========================# +# Registration and Login # +#========================# + +ALLOW_EMAIL_LOGIN=true +ALLOW_REGISTRATION=true +ALLOW_SOCIAL_LOGIN=false +ALLOW_SOCIAL_REGISTRATION=false +ALLOW_PASSWORD_RESET=false +# ALLOW_ACCOUNT_DELETION=true # note: enabled by default if omitted/commented out +ALLOW_UNVERIFIED_EMAIL_LOGIN=true + +SESSION_EXPIRY=1000 * 60 * 15 +REFRESH_TOKEN_EXPIRY=(1000 * 60 * 60 * 24) * 7 + +JWT_SECRET=16f8c0ef4a5d391b26034086c628469d3f9f497f08163ab9b40137092f2909ef +JWT_REFRESH_SECRET=eaa5191f2914e30b9387fd84e254e4ba6fc51b4654968a9b0803b456a54b8418 + +# Discord +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= +DISCORD_CALLBACK_URL=/oauth/discord/callback + +# Facebook +FACEBOOK_CLIENT_ID= +FACEBOOK_CLIENT_SECRET= +FACEBOOK_CALLBACK_URL=/oauth/facebook/callback + +# GitHub +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +GITHUB_CALLBACK_URL=/oauth/github/callback +# GitHub Enterprise +# GITHUB_ENTERPRISE_BASE_URL= +# GITHUB_ENTERPRISE_USER_AGENT= + +# Google +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_CALLBACK_URL=/oauth/google/callback + +# Apple +APPLE_CLIENT_ID= +APPLE_TEAM_ID= +APPLE_KEY_ID= +APPLE_PRIVATE_KEY_PATH= +APPLE_CALLBACK_URL=/oauth/apple/callback + +# OpenID +OPENID_CLIENT_ID= +OPENID_CLIENT_SECRET= +OPENID_ISSUER= +OPENID_SESSION_SECRET= +OPENID_SCOPE="openid profile email" +OPENID_CALLBACK_URL=/oauth/openid/callback +OPENID_REQUIRED_ROLE= +OPENID_REQUIRED_ROLE_TOKEN_KIND= +OPENID_REQUIRED_ROLE_PARAMETER_PATH= +OPENID_ADMIN_ROLE= +OPENID_ADMIN_ROLE_PARAMETER_PATH= +OPENID_ADMIN_ROLE_TOKEN_KIND= +# Set to determine which user info property returned from OpenID Provider to store as the User's username +OPENID_USERNAME_CLAIM= +# Set to determine which user info property returned from OpenID Provider to store as the User's name +OPENID_NAME_CLAIM= +# Set to determine which user info claim to use as the email/identifier for user matching (e.g., "upn" for Entra ID) +# When not set, defaults to: email -> preferred_username -> upn +OPENID_EMAIL_CLAIM= +# Optional audience parameter for OpenID authorization requests +OPENID_AUDIENCE= + +OPENID_BUTTON_LABEL= +OPENID_IMAGE_URL= +# Set to true to automatically redirect to the OpenID provider when a user visits the login page +# This will bypass the login form completely for users, only use this if OpenID is your only authentication method +OPENID_AUTO_REDIRECT=false +# Set to true to use PKCE (Proof Key for Code Exchange) for OpenID authentication +OPENID_USE_PKCE=false +#Set to true to reuse openid tokens for authentication management instead of using the mongodb session and the custom refresh token. +OPENID_REUSE_TOKENS= +#By default, signing key verification results are cached in order to prevent excessive HTTP requests to the JWKS endpoint. +#If a signing key matching the kid is found, this will be cached and the next time this kid is requested the signing key will be served from the cache. +#Default is true. +OPENID_JWKS_URL_CACHE_ENABLED= +OPENID_JWKS_URL_CACHE_TIME= # 600000 ms eq to 10 minutes leave empty to disable caching +#Set to true to trigger token exchange flow to acquire access token for the userinfo endpoint. +OPENID_ON_BEHALF_FLOW_FOR_USERINFO_REQUIRED= +OPENID_ON_BEHALF_FLOW_USERINFO_SCOPE="user.read" # example for Scope Needed for Microsoft Graph API +# Set to true to use the OpenID Connect end session endpoint for logout +OPENID_USE_END_SESSION_ENDPOINT= +# URL to redirect to after OpenID logout (defaults to ${DOMAIN_CLIENT}/login) +OPENID_POST_LOGOUT_REDIRECT_URI= +# Maximum logout URL length before using logout_hint instead of id_token_hint (default: 2000) +OPENID_MAX_LOGOUT_URL_LENGTH= + +#========================# +# SharePoint Integration # +#========================# +# Requires Entra ID (OpenID) authentication to be configured + +# Enable SharePoint file picker in chat and agent panels +# ENABLE_SHAREPOINT_FILEPICKER=true + +# SharePoint tenant base URL (e.g., https://yourtenant.sharepoint.com) +# SHAREPOINT_BASE_URL=https://yourtenant.sharepoint.com + +# Microsoft Graph API And SharePoint scopes for file picker +# SHAREPOINT_PICKER_SHAREPOINT_SCOPE==https://yourtenant.sharepoint.com/AllSites.Read +# SHAREPOINT_PICKER_GRAPH_SCOPE=Files.Read.All +#========================# + +# SAML +# Note: If OpenID is enabled, SAML authentication will be automatically disabled. +SAML_ENTRY_POINT= +SAML_ISSUER= +SAML_CERT= +SAML_CALLBACK_URL=/oauth/saml/callback +SAML_SESSION_SECRET= + +# Attribute mappings (optional) +SAML_EMAIL_CLAIM= +SAML_USERNAME_CLAIM= +SAML_GIVEN_NAME_CLAIM= +SAML_FAMILY_NAME_CLAIM= +SAML_PICTURE_CLAIM= +SAML_NAME_CLAIM= + +# Logint buttion settings (optional) +SAML_BUTTON_LABEL= +SAML_IMAGE_URL= + +# Whether the SAML Response should be signed. +# - If "true", the entire `SAML Response` will be signed. +# - If "false" or unset, only the `SAML Assertion` will be signed (default behavior). +# SAML_USE_AUTHN_RESPONSE_SIGNED= + + +#===============================================# +# Microsoft Graph API / Entra ID Integration # +#===============================================# + +# Enable Entra ID people search integration in permissions/sharing system +# When enabled, the people picker will search both local database and Entra ID +USE_ENTRA_ID_FOR_PEOPLE_SEARCH=false + +# When enabled, entra id groups owners will be considered as members of the group +ENTRA_ID_INCLUDE_OWNERS_AS_MEMBERS=false + +# Microsoft Graph API scopes needed for people/group search +# Default scopes provide access to user profiles and group memberships +OPENID_GRAPH_SCOPES=User.Read,People.Read,GroupMember.Read.All + +# LDAP +LDAP_URL= +LDAP_BIND_DN= +LDAP_BIND_CREDENTIALS= +LDAP_USER_SEARCH_BASE= +#LDAP_SEARCH_FILTER="mail=" +LDAP_CA_CERT_PATH= +# LDAP_TLS_REJECT_UNAUTHORIZED= +# LDAP_STARTTLS= +# LDAP_LOGIN_USES_USERNAME=true +# LDAP_ID= +# LDAP_USERNAME= +# LDAP_EMAIL= +# LDAP_FULL_NAME= + +#========================# +# Email Password Reset # +#========================# + +EMAIL_SERVICE= +EMAIL_HOST= +EMAIL_PORT=25 +EMAIL_ENCRYPTION= +EMAIL_ENCRYPTION_HOSTNAME= +EMAIL_ALLOW_SELFSIGNED= +# Leave both empty for SMTP servers that do not require authentication +EMAIL_USERNAME= +EMAIL_PASSWORD= +EMAIL_FROM_NAME= +EMAIL_FROM=noreply@librechat.ai + +#========================# +# Mailgun API # +#========================# + +# MAILGUN_API_KEY=your-mailgun-api-key +# MAILGUN_DOMAIN=mg.yourdomain.com +# EMAIL_FROM=noreply@yourdomain.com +# EMAIL_FROM_NAME="LibreChat" + +# # Optional: For EU region +# MAILGUN_HOST=https://api.eu.mailgun.net + +#========================# +# Firebase CDN # +#========================# + +FIREBASE_API_KEY= +FIREBASE_AUTH_DOMAIN= +FIREBASE_PROJECT_ID= +FIREBASE_STORAGE_BUCKET= +FIREBASE_MESSAGING_SENDER_ID= +FIREBASE_APP_ID= + +#========================# +# S3 AWS Bucket # +#========================# + +AWS_ENDPOINT_URL= +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_REGION= +AWS_BUCKET_NAME= +# Required for path-style S3-compatible providers (MinIO, Hetzner, Backblaze B2, etc.) +# that don't support virtual-hosted-style URLs (bucket.endpoint). Not needed for AWS S3. +# AWS_FORCE_PATH_STYLE=false + +#========================# +# Azure Blob Storage # +#========================# + +AZURE_STORAGE_CONNECTION_STRING= +AZURE_STORAGE_PUBLIC_ACCESS=false +AZURE_CONTAINER_NAME=files + +#========================# +# Shared Links # +#========================# + +ALLOW_SHARED_LINKS=true +# Allows unauthenticated access to shared links. Defaults to false (auth required) if not set. +ALLOW_SHARED_LINKS_PUBLIC=false + +#==============================# +# Static File Cache Control # +#==============================# + +# Leave commented out to use defaults: 1 day (86400 seconds) for s-maxage and 2 days (172800 seconds) for max-age +# NODE_ENV must be set to production for these to take effect +# STATIC_CACHE_MAX_AGE=172800 +# STATIC_CACHE_S_MAX_AGE=86400 + +# If you have another service in front of your LibreChat doing compression, disable express based compression here +# DISABLE_COMPRESSION=true + +# If you have gzipped version of uploaded image images in the same folder, this will enable gzip scan and serving of these images +# Note: The images folder will be scanned on startup and a ma kept in memory. Be careful for large number of images. +# ENABLE_IMAGE_OUTPUT_GZIP_SCAN=true + +#===================================================# +# UI # +#===================================================# + +APP_TITLE=LibreChat +# CUSTOM_FOOTER="My custom footer" +HELP_AND_FAQ_URL=https://librechat.ai + +# SHOW_BIRTHDAY_ICON=true + +# Google tag manager id +#ANALYTICS_GTM_ID=user provided google tag manager id + +# limit conversation file imports to a certain number of bytes in size to avoid the container +# maxing out memory limitations by unremarking this line and supplying a file size in bytes +# such as the below example of 250 mib +# CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES=262144000 + + +#===============# +# REDIS Options # +#===============# + +# Enable Redis for caching and session storage +# USE_REDIS=true +# Enable Redis for resumable LLM streams (defaults to USE_REDIS value if not set) +# Set to false to use in-memory storage for streams while keeping Redis for other caches +# USE_REDIS_STREAMS=true + +# Single Redis instance +# REDIS_URI=redis://127.0.0.1:6379 + +# Redis cluster (multiple nodes) +# REDIS_URI=redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003 + +# Redis with TLS/SSL encryption and CA certificate +# REDIS_URI=rediss://127.0.0.1:6380 +# REDIS_CA=/path/to/ca-cert.pem + +# Elasticache may need to use an alternate dnsLookup for TLS connections. see "Special Note: Aws Elasticache Clusters with TLS" on this webpage: https://www.npmjs.com/package/ioredis +# Enable alternative dnsLookup for redis +# REDIS_USE_ALTERNATIVE_DNS_LOOKUP=true + +# Redis authentication (if required) +# REDIS_USERNAME=your_redis_username +# REDIS_PASSWORD=your_redis_password + +# Redis key prefix configuration +# Use environment variable name for dynamic prefix (recommended for cloud deployments) +# REDIS_KEY_PREFIX_VAR=K_REVISION +# Or use static prefix directly +# REDIS_KEY_PREFIX=librechat + +# Redis connection limits +# REDIS_MAX_LISTENERS=40 + +# Redis ping interval in seconds (0 = disabled, >0 = enabled) +# When set to a positive integer, Redis clients will ping the server at this interval to keep connections alive +# When unset or 0, no pinging is performed (recommended for most use cases) +# REDIS_PING_INTERVAL=300 + +# Force specific cache namespaces to use in-memory storage even when Redis is enabled +# Comma-separated list of CacheKeys +# Defaults to CONFIG_STORE,APP_CONFIG so YAML-derived config stays per-container (safe for blue/green deployments) +# Set to empty string to force all namespaces through Redis: FORCED_IN_MEMORY_CACHE_NAMESPACES= +# FORCED_IN_MEMORY_CACHE_NAMESPACES=CONFIG_STORE,APP_CONFIG + +# Leader Election Configuration (for multi-instance deployments with Redis) +# Duration in seconds that the leader lease is valid before it expires (default: 25) +# LEADER_LEASE_DURATION=25 +# Interval in seconds at which the leader renews its lease (default: 10) +# LEADER_RENEW_INTERVAL=10 +# Maximum number of retry attempts when renewing the lease fails (default: 3) +# LEADER_RENEW_ATTEMPTS=3 +# Delay in seconds between retry attempts when renewing the lease (default: 0.5) +# LEADER_RENEW_RETRY_DELAY=0.5 + +#==================================================# +# Others # +#==================================================# +# You should leave the following commented out # + +# NODE_ENV= + +# E2E_USER_EMAIL= +# E2E_USER_PASSWORD= + +#=====================================================# +# Cache Headers # +#=====================================================# +# Headers that control caching of the index.html # +# Default configuration prevents caching to ensure # +# users always get the latest version. Customize # +# only if you understand caching implications. # + +# INDEX_CACHE_CONTROL=no-cache, no-store, must-revalidate +# INDEX_PRAGMA=no-cache +# INDEX_EXPIRES=0 + +# no-cache: Forces validation with server before using cached version +# no-store: Prevents storing the response entirely +# must-revalidate: Prevents using stale content when offline + +#=====================================================# +# OpenWeather # +#=====================================================# +OPENWEATHER_API_KEY= + +#====================================# +# LibreChat Code Interpreter API # +#====================================# + +# https://code.librechat.ai +# LIBRECHAT_CODE_API_KEY=your-key + +#======================# +# Web Search # +#======================# + +# Note: All of the following variable names can be customized. +# Omit values to allow user to provide them. + +# For more information on configuration values, see: +# https://librechat.ai/docs/features/web_search + +# Search Provider (Required) +# SERPER_API_KEY=your_serper_api_key + +# Scraper (Required) +# FIRECRAWL_API_KEY=your_firecrawl_api_key +# Optional: Custom Firecrawl API URL +# FIRECRAWL_API_URL=your_firecrawl_api_url + +# Reranker (Required) +# JINA_API_KEY=your_jina_api_key +# or +# COHERE_API_KEY=your_cohere_api_key + +#======================# +# MCP Configuration # +#======================# + +# Treat 401/403 responses as OAuth requirement when no oauth metadata found +# MCP_OAUTH_ON_AUTH_ERROR=true + +# Timeout for OAuth detection requests in milliseconds +# MCP_OAUTH_DETECTION_TIMEOUT=5000 + +# Cache connection status checks for this many milliseconds to avoid expensive verification +# MCP_CONNECTION_CHECK_TTL=60000 + +# Skip code challenge method validation (e.g., for AWS Cognito that supports S256 but doesn't advertise it) +# When set to true, forces S256 code challenge even if not advertised in .well-known/openid-configuration +# MCP_SKIP_CODE_CHALLENGE_CHECK=false + +# Circuit breaker: max connect/disconnect cycles before tripping (per server) +# MCP_CB_MAX_CYCLES=7 + +# Circuit breaker: sliding window (ms) for counting cycles +# MCP_CB_CYCLE_WINDOW_MS=45000 + +# Circuit breaker: cooldown (ms) after the cycle breaker trips +# MCP_CB_CYCLE_COOLDOWN_MS=15000 + +# Circuit breaker: max consecutive failed connection rounds before backoff +# MCP_CB_MAX_FAILED_ROUNDS=3 + +# Circuit breaker: sliding window (ms) for counting failed rounds +# MCP_CB_FAILED_WINDOW_MS=120000 + +# Circuit breaker: base backoff (ms) after failed round threshold is reached +# MCP_CB_BASE_BACKOFF_MS=30000 + +# Circuit breaker: max backoff cap (ms) for exponential backoff +# MCP_CB_MAX_BACKOFF_MS=300000 diff --git a/librechat/librechat.yaml b/librechat/librechat.yaml index 399518c..464be93 100644 --- a/librechat/librechat.yaml +++ b/librechat/librechat.yaml @@ -6,9 +6,7 @@ cache: true endpoints: anthropic: apiKey: "${ANTHROPIC_API_KEY}" - models: - default: ["claude-sonnet-4-5", "claude-haiku-4-5", "claude-opus-4-5"] - fetch: false + models: ["claude-sonnet-4-5", "claude-haiku-4-5", "claude-opus-4-5"] titleConvo: true titleModel: "claude-haiku-4-5" modelDisplayLabel: "Claude AI" @@ -72,7 +70,7 @@ endpoints: apiKey: "dummy_key" baseURL: "http://alfred:8000/v1" models: - default: ["local-deepseek-agent"] + default: ["glm-4.7-flash:latest"] fetch: false titleConvo: false titleModel: "current_model" diff --git a/mongod.conf b/mongod.conf index 2e1278b..0b30d87 100644 --- a/mongod.conf +++ b/mongod.conf @@ -13,7 +13,7 @@ storage: systemLog: destination: file path: /dev/stdout - logAppend: false + logAppend: true verbosity: 0 quiet: true component: diff --git a/scripts/bootstrap.py b/scripts/bootstrap.py index 613903e..a3a5d4f 100644 --- a/scripts/bootstrap.py +++ b/scripts/bootstrap.py @@ -87,6 +87,39 @@ def extract_python_version(version_string: str) -> tuple[str, str]: raise ValueError(f"Invalid Python version: {version_string}") +def build_uris(env_alfred: Path, env_secrets: Path) -> None: + """Build MONGO_URI and POSTGRES_URI from components and append them to .env.secrets.""" + env = {**load_env_file(env_alfred), **load_env_file(env_secrets)} + existing = load_env_file(env_secrets) + + computed = { + "MONGO_URI": ( + f"mongodb://{env['MONGO_USER']}:{env['MONGO_PASSWORD']}" + f"@{env['MONGO_HOST']}:{env['MONGO_PORT']}/{env['MONGO_DB_NAME']}" + f"?authSource=admin" + ), + "POSTGRES_URI": ( + f"postgresql://{env['POSTGRES_USER']}:{env['POSTGRES_PASSWORD']}" + f"@{env['POSTGRES_HOST']}:{env['POSTGRES_PORT']}/{env['POSTGRES_DB_NAME']}" + ), + } + + content = env_secrets.read_text() + added = [] + for key, value in computed.items(): + if key in existing: + content = re.sub(rf"^{key}=.*$", f"{key}={value}", content, flags=re.MULTILINE) + else: + content = content.rstrip("\n") + f"\n{key}={value}\n" + added.append(key) + env_secrets.write_text(content) + + if added: + print(f" + Computed: {', '.join(added)}") + else: + print(" ↻ URIs updated") + + def write_env_make(toml_data: dict) -> None: """Write .env.make from pyproject.toml.""" project = toml_data["project"] @@ -96,14 +129,13 @@ def write_env_make(toml_data: dict) -> None: lines = [ "# Auto-generated from pyproject.toml — do not edit manually", - f"export ALFRED_VERSION={project['version']}", - f"export PYTHON_VERSION={python_full}", - f"export PYTHON_VERSION_SHORT={python_short}", - f"export IMAGE_NAME={alfred['image_name']}", - f"export SERVICE_NAME={alfred['service_name']}", - f"export LIBRECHAT_VERSION={alfred['librechat_version']}", - f"export RAG_VERSION={alfred['rag_version']}", - f"export UV_VERSION={alfred['uv_version']}", + f"ALFRED_VERSION={project['version']}", + f"PYTHON_VERSION={python_full}", + f"IMAGE_NAME={alfred['image_name']}", + f"SERVICE_NAME={alfred['service_name']}", + f"LIBRECHAT_VERSION={alfred['librechat_version']}", + f"RAG_VERSION={alfred['rag_version']}", + f"UV_VERSION={alfred['uv_version']}", ] env_make_path = BASE_DIR / ".env.make" @@ -138,6 +170,9 @@ def main() -> int: print("\n🔐 Secrets:") generate_secrets_file(BASE_DIR / ".env.secrets", secrets_spec) + print("\n🔗 URIs:") + build_uris(BASE_DIR / ".env.alfred", BASE_DIR / ".env.secrets") + print("\n🔧 Build config:") write_env_make(toml_data) diff --git a/scripts/config_loader.py b/scripts/config_loader.py index dabc210..7cbc6a2 100644 --- a/scripts/config_loader.py +++ b/scripts/config_loader.py @@ -72,7 +72,6 @@ def write_env_make(config: BuildConfig, base_dir: Path | None = None) -> None: "# Auto-generated from pyproject.toml — do not edit manually", f"export ALFRED_VERSION={config.alfred_version}", f"export PYTHON_VERSION={config.python_version}", - f"export PYTHON_VERSION_SHORT={config.python_version_short}", f"export IMAGE_NAME={config.image_name}", f"export SERVICE_NAME={config.service_name}", f"export LIBRECHAT_VERSION={config.librechat_version}", diff --git a/scripts/generate_build_vars.py b/scripts/generate_build_vars.py deleted file mode 100644 index 8a9ebbf..0000000 --- a/scripts/generate_build_vars.py +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env python3 -"""Generate .env.make for CI/CD without generating secrets.""" - -import sys - -from config_loader import load_build_config, write_env_make - - -def main(): - """Generate .env.make from pyproject.toml.""" - try: - config = load_build_config() - write_env_make(config) - print("✅ .env.make generated successfully.") - return 0 - except Exception as e: - print(f"❌ Failed to generate .env.make: {e}") - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py new file mode 100644 index 0000000..3d5f536 --- /dev/null +++ b/tests/test_bootstrap.py @@ -0,0 +1,281 @@ +"""Tests for scripts/bootstrap.py — focus on secret safety and idempotency.""" + +import sys +from pathlib import Path + +import pytest + +# bootstrap.py lives in scripts/, not in a package — add it to path +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) + +from bootstrap import ( + build_uris, + copy_example_if_missing, + extract_python_version, + generate_secrets_file, + load_env_file, +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +SECRETS_SPEC = { + "JWT_SECRET": (32, "hex"), + "MONGO_PASSWORD": (16, "hex"), + "POSTGRES_PASSWORD": (16, "hex"), +} + +ALFRED_ENV = """\ +MONGO_HOST=mongodb +MONGO_PORT=27017 +MONGO_USER=alfred +MONGO_DB_NAME=mydb +POSTGRES_HOST=vectordb +POSTGRES_PORT=5432 +POSTGRES_USER=alfred +POSTGRES_DB_NAME=alfred +""" + +SECRETS_ENV = """\ +# Auto-generated secrets — DO NOT COMMIT +JWT_SECRET=deadbeef +MONGO_PASSWORD=cafebabe +POSTGRES_PASSWORD=f00dface +""" + + +@pytest.fixture +def secrets_file(tmp_path): + """An existing .env.secrets with pre-generated values.""" + p = tmp_path / ".env.secrets" + p.write_text(SECRETS_ENV) + return p + + +@pytest.fixture +def alfred_file(tmp_path): + p = tmp_path / ".env.alfred" + p.write_text(ALFRED_ENV) + return p + + +# --------------------------------------------------------------------------- +# load_env_file +# --------------------------------------------------------------------------- + + +class TestLoadEnvFile: + def test_parses_key_value_pairs(self, tmp_path): + f = tmp_path / ".env" + f.write_text("FOO=bar\nBAZ=qux\n") + assert load_env_file(f) == {"FOO": "bar", "BAZ": "qux"} + + def test_ignores_comments_and_blanks(self, tmp_path): + f = tmp_path / ".env" + f.write_text("# comment\n\nFOO=bar\n") + assert load_env_file(f) == {"FOO": "bar"} + + def test_missing_file_returns_empty(self, tmp_path): + assert load_env_file(tmp_path / "nonexistent") == {} + + def test_value_with_equals_sign(self, tmp_path): + """Values containing '=' must be preserved intact (e.g. base64).""" + f = tmp_path / ".env" + f.write_text("KEY=abc=def==\n") + assert load_env_file(f)["KEY"] == "abc=def==" + + +# --------------------------------------------------------------------------- +# generate_secrets_file — the critical ones +# --------------------------------------------------------------------------- + + +class TestGenerateSecretsFile: + def test_generates_all_secrets_on_first_run(self, tmp_path): + path = tmp_path / ".env.secrets" + generate_secrets_file(path, SECRETS_SPEC) + + result = load_env_file(path) + assert set(SECRETS_SPEC.keys()) <= result.keys() + assert all(result[k] for k in SECRETS_SPEC) # non-empty + + def test_never_overwrites_existing_secrets(self, secrets_file): + """Core safety property: running bootstrap again must not change existing values.""" + before = load_env_file(secrets_file) + + generate_secrets_file(secrets_file, SECRETS_SPEC) + + after = load_env_file(secrets_file) + for key in before: + assert after[key] == before[key], f"{key} was overwritten!" + + def test_adds_missing_secrets_without_touching_existing(self, secrets_file): + """Only keys absent from the file should be added.""" + before = load_env_file(secrets_file) + # POSTGRES_PASSWORD already exists; JWT_SECRET already exists + # Add a new key to the spec that is not yet in the file + spec = {**SECRETS_SPEC, "NEW_SECRET": (16, "hex")} + + generate_secrets_file(secrets_file, spec) + + after = load_env_file(secrets_file) + # Existing values untouched + for key in before: + assert after[key] == before[key] + # New key added + assert "NEW_SECRET" in after + assert len(after["NEW_SECRET"]) == 32 # 16 bytes → 32 hex chars + + def test_idempotent_across_multiple_runs(self, tmp_path): + """Calling bootstrap N times must produce stable secrets.""" + path = tmp_path / ".env.secrets" + + generate_secrets_file(path, SECRETS_SPEC) + after_first = load_env_file(path) + + generate_secrets_file(path, SECRETS_SPEC) + after_second = load_env_file(path) + + assert after_first == after_second + + def test_hex_secret_has_correct_length(self, tmp_path): + path = tmp_path / ".env.secrets" + generate_secrets_file(path, {"MY_KEY": (32, "hex")}) + value = load_env_file(path)["MY_KEY"] + assert len(value) == 64 # 32 bytes → 64 hex chars + assert all(c in "0123456789abcdef" for c in value) + + def test_preserves_comments_in_existing_file(self, secrets_file): + """Comments in .env.secrets must survive a bootstrap run.""" + generate_secrets_file(secrets_file, SECRETS_SPEC) + content = secrets_file.read_text() + assert "# Auto-generated secrets" in content + + +# --------------------------------------------------------------------------- +# build_uris +# --------------------------------------------------------------------------- + + +class TestBuildUris: + def test_writes_uris_to_secrets_file(self, alfred_file, secrets_file): + build_uris(alfred_file, secrets_file) + result = load_env_file(secrets_file) + + assert "MONGO_URI" in result + assert "POSTGRES_URI" in result + + def test_mongo_uri_contains_all_components(self, alfred_file, secrets_file): + build_uris(alfred_file, secrets_file) + uri = load_env_file(secrets_file)["MONGO_URI"] + + assert "alfred" in uri # user + assert "cafebabe" in uri # password from secrets + assert "mongodb" in uri # host + assert "27017" in uri # port + assert "mydb" in uri # dbname + assert "authSource=admin" in uri + + def test_postgres_uri_contains_all_components(self, alfred_file, secrets_file): + build_uris(alfred_file, secrets_file) + uri = load_env_file(secrets_file)["POSTGRES_URI"] + + assert "alfred" in uri + assert "f00dface" in uri # password from secrets + assert "vectordb" in uri + assert "5432" in uri + assert uri.startswith("postgresql://") + + def test_uri_is_updated_when_host_changes(self, tmp_path, secrets_file): + """If MONGO_HOST changes in .env.alfred, the URI must reflect it.""" + alfred = tmp_path / ".env.alfred" + alfred.write_text(ALFRED_ENV.replace("MONGO_HOST=mongodb", "MONGO_HOST=newhost")) + + build_uris(alfred, secrets_file) + uri = load_env_file(secrets_file)["MONGO_URI"] + + assert "@newhost:" in uri + assert "@mongodb:" not in uri + + def test_uri_update_does_not_alter_other_secrets(self, alfred_file, secrets_file): + """Recomputing URIs must not touch JWT_SECRET or passwords.""" + before = load_env_file(secrets_file) + + build_uris(alfred_file, secrets_file) + + after = load_env_file(secrets_file) + for key in before: + assert after[key] == before[key], f"{key} was altered by build_uris!" + + def test_uri_recomputed_on_repeated_calls(self, tmp_path, secrets_file): + """Calling build_uris twice with different configs produces the latest URI.""" + alfred_v1 = tmp_path / "alfred_v1" + alfred_v1.write_text(ALFRED_ENV) + build_uris(alfred_v1, secrets_file) + uri_v1 = load_env_file(secrets_file)["MONGO_URI"] + + alfred_v2 = tmp_path / "alfred_v2" + alfred_v2.write_text(ALFRED_ENV.replace("MONGO_DB_NAME=mydb", "MONGO_DB_NAME=otherdb")) + build_uris(alfred_v2, secrets_file) + uri_v2 = load_env_file(secrets_file)["MONGO_URI"] + + assert "mydb" not in uri_v2 + assert "otherdb" in uri_v2 + # Password unchanged across both calls + assert load_env_file(secrets_file)["MONGO_PASSWORD"] == "cafebabe" + + +# --------------------------------------------------------------------------- +# copy_example_if_missing +# --------------------------------------------------------------------------- + + +class TestCopyExampleIfMissing: + def test_copies_when_dst_missing(self, tmp_path): + src = tmp_path / "src.env" + src.write_text("FOO=bar\n") + dst = tmp_path / "dst.env" + + copy_example_if_missing(src, dst, "test") + + assert dst.read_text() == "FOO=bar\n" + + def test_never_overwrites_existing_dst(self, tmp_path): + src = tmp_path / "src.env" + src.write_text("FOO=new\n") + dst = tmp_path / "dst.env" + dst.write_text("FOO=original\n") + + copy_example_if_missing(src, dst, "test") + + assert dst.read_text() == "FOO=original\n" + + def test_silent_skip_when_src_missing(self, tmp_path): + """Should not raise if the example file doesn't exist yet.""" + dst = tmp_path / "dst.env" + copy_example_if_missing(tmp_path / "nonexistent.env", dst, "test") + assert not dst.exists() + + +# --------------------------------------------------------------------------- +# extract_python_version +# --------------------------------------------------------------------------- + + +class TestExtractPythonVersion: + @pytest.mark.parametrize("spec,expected_full,expected_short", [ + ("==3.14.3", "3.14.3", "3.14"), + ("^3.12.0", "3.12.0", "3.12"), + ("~3.11.1", "3.11.1", "3.11"), + ("3.10.5", "3.10.5", "3.10"), + ]) + def test_parses_version_specifiers(self, spec, expected_full, expected_short): + full, short = extract_python_version(spec) + assert full == expected_full + assert short == expected_short + + def test_raises_on_invalid_version(self): + with pytest.raises(ValueError): + extract_python_version("3")