diff --git a/.env.example b/.env.example index e35430f64..55aa7dd56 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,9 @@ VLM_MODEL= # Set to true, it will mount chainlit chat ui to the fastapi app (Default: true) ## WITH_CHAINLIT_UI=true +# Signs Chainlit UI session cookies. Required when the UI is enabled (unless +# ALLOW_NO_AUTH=true). Generate: python -c "import secrets; print(secrets.token_hex(32))" +# CHAINLIT_AUTH_SECRET= # UI language for Chainlit and Indexer UI (e.g. en-US, fr). # When unset, the browser language is used, with en-US as the final fallback. @@ -51,6 +54,14 @@ RERANKER_MODEL=Alibaba-NLP/gte-multilingual-reranker-base # or jinaai/jina-reran # API accepts natively. Override to restrict (e.g. to ".wav" only for vLLM deployments). # TRANSCRIBER_DIRECT_UPLOAD_SUFFIXES=.wav|.flac|.ogg|.mp3|.mp4|.m4a|.webm|.mpeg|.mpga +# MinIO (used by Milvus) — required, no default. e.g. `openssl rand -hex 16`. +MINIO_ACCESS_KEY= +MINIO_SECRET_KEY= + +# PostgreSQL — required, no default. e.g. `openssl rand -hex 16`. +POSTGRES_PASSWORD= +# POSTGRES_USER=root + # Prompts PROMPTS_DIR=../prompts/example1 @@ -83,8 +94,18 @@ API_BASE_URL='http://X.X.X.X:APP_PORT' # Base URL of your FastAPI backe # WEBSEARCH_TOP_K=5 # Number of web results to include (default: 5) # WEBSEARCH_LANG=fr-FR # Search language/market (default: fr-FR) +# Max partitions a non-admin user may own (-1 = unlimited; admins bypass). +# MAX_PARTITIONS_PER_USER=100 + +# Rate limiting (per-worker moving window, keyed on user id then client IP) +# RATE_LIMIT_ENABLED=true +# RATE_LIMIT_DEFAULT=300/minute # all paths except those below +# RATE_LIMIT_AUTH=20/minute # /auth/* (login/callback/logout) +# RATE_LIMIT_CHAT=60/minute # /v1/* (chat completions, tools) + # LOGGING -LOG_LEVEL=DEBUG # See possible values https://loguru.readthedocs.io/en/stable/api/logger.html +# INFO by default; DEBUG persists user queries and request data to logs. +LOG_LEVEL=INFO # See possible values https://loguru.readthedocs.io/en/stable/api/logger.html # SERVER # Set the preferred URL scheme for generated URLs (e.g., task_status_url). diff --git a/Dockerfile b/Dockerfile index 01e35d3d4..da9a62147 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,6 +27,10 @@ ENV HF_HUB_CACHE=${HF_HUB_CACHE:-/app/model_weights/hub} # Set workdir for uv WORKDIR /app +# Set HOME before installing so uv's Python and cache land under /app (owned by +# the non-root user below), not /root. +ENV HOME=/app + # Install uv & setup venv COPY pyproject.toml uv.lock ./ RUN pip3 install uv && \ @@ -46,4 +50,13 @@ COPY prompts/ /app/prompts/ COPY conf/ /app/conf/ ENV PYTHONPATH=/app/openrag/ ENV APP_iPORT=${APP_iPORT:-8080} + +# Run as non-root. The app writes under /app (venv, data, logs, model_weights), +# so the user owns /app. +RUN groupadd --gid 10001 app \ + && useradd --uid 10001 --gid 10001 --home-dir /app --no-create-home app \ + && mkdir -p /app/data /app/logs /app/model_weights \ + && chown -R 10001:10001 /app +USER 10001:10001 + ENTRYPOINT ../entrypoint.sh diff --git a/Dockerfile.ray b/Dockerfile.ray index f56ea6370..af9dd33fb 100644 --- a/Dockerfile.ray +++ b/Dockerfile.ray @@ -31,6 +31,10 @@ ENV HF_HUB_CACHE=${HF_HUB_CACHE:-/app/model_weights/hub} # Set workdir for uv WORKDIR /app +# Set HOME before installing so uv's Python and cache land under /app (owned by +# the non-root user below), not /root. +ENV HOME=/app + # Install uv & setup venv COPY pyproject.toml uv.lock ./ RUN pip3 install uv && \ @@ -52,4 +56,12 @@ COPY conf/ /app/conf/ RUN ln -s /app/.venv/bin/ray /usr/local/bin/ray -ENV PYTHONPATH=/app/openrag/ \ No newline at end of file +ENV PYTHONPATH=/app/openrag/ + +# Run as non-root. The app writes under /app (venv, data, logs, model_weights), +# so the user owns /app. +RUN groupadd --gid 10001 app \ + && useradd --uid 10001 --gid 10001 --home-dir /app --no-create-home app \ + && mkdir -p /app/data /app/logs /app/model_weights \ + && chown -R 10001:10001 /app +USER 10001:10001 \ No newline at end of file diff --git a/charts/openrag-stack/templates/networkpolicy.yaml b/charts/openrag-stack/templates/networkpolicy.yaml new file mode 100644 index 000000000..911d988b1 --- /dev/null +++ b/charts/openrag-stack/templates/networkpolicy.yaml @@ -0,0 +1,29 @@ +{{- if .Values.networkPolicy.enabled }} +# Default-deny ingress for all pods, allowing only same-namespace traffic and +# the public HTTP ports. Keeps the Ray dashboard (8265), Postgres, Milvus, etc. +# off-limits from outside the namespace. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "openrag-stack.fullname" . }}-default-deny + labels: + {{- include "openrag-stack.labels" . | nindent 4 }} +spec: + podSelector: {} + policyTypes: + - Ingress + ingress: + # 1. Allow all traffic originating from pods in this namespace. + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ .Release.Namespace }} + # 2. Allow the public HTTP ports from any source (e.g. the Ingress controller). + {{- with .Values.networkPolicy.externalPorts }} + - ports: + {{- range . }} + - port: {{ . }} + protocol: TCP + {{- end }} + {{- end }} +{{- end }} diff --git a/charts/openrag-stack/values.yaml b/charts/openrag-stack/values.yaml index 197b9c98a..d392a3ab5 100644 --- a/charts/openrag-stack/values.yaml +++ b/charts/openrag-stack/values.yaml @@ -1,3 +1,13 @@ +# === Network isolation === +# Default-deny ingress; allow only same-namespace traffic and the public HTTP +# ports below. Isolates the Ray dashboard, Postgres, Milvus, etc. +networkPolicy: + enabled: true + # Ports reachable from outside the namespace (e.g. via the Ingress controller). + externalPorts: + - 8080 # openrag API/UI + - 3000 # indexer-ui + # === Global shared persistence === persistence: enabled: true @@ -20,14 +30,16 @@ ray: gpu-role: serving image: repository: ghcr.io/linagora/openrag-ray - tag: latest + # Pin to a release tag (ideally a digest) for reproducible deploys. + tag: "1.1.11" # === PostgreSQL (bitnami) === postgresql: enabled: true auth: username: &pgUser root - password: &pgPass root_password + # Override at install, e.g. --set postgresql.auth.password=$(openssl rand -hex 16) + password: &pgPass "CHANGE_ME_STRONG_PASSWORD" primary: persistence: enabled: true @@ -72,6 +84,8 @@ vllm: llmModelName: &llmModel "RedHatAI/Mistral-Small-3.1-24B-Instruct-2503-quantized.w8a8" servingEngineSpec: enableEngine: true + # Pin the per-model `tag: "latest"` entries below to specific releases before + # production (see vlm, pinned to v0.11.2). modelSpec: - name: "embedder" repository: "vllm/vllm-openai" @@ -182,6 +196,8 @@ reranker: servicePort: &rerankerPort 7997 image: repository: michaelf34/infinity + # Operator-supplied serving image: pin to a specific infinity release tag + # (or digest) before production use rather than tracking latest. tag: latest nodeSelector: gpu-role: serving @@ -209,7 +225,7 @@ reranker: indexerUi: enabled: true - image: "linagoraai/indexer-ui:latest" + image: "linagoraai/indexer-ui:1.1.11" imagePullPolicy: IfNotPresent replicaCount: 1 @@ -232,7 +248,8 @@ indexerUi: openrag: image: repository: linagoraai/openrag - tag: latest + # Pin to a release tag (ideally a digest) for reproducible deploys. + tag: "1.1.11" service: type: ClusterIP port: 8080 @@ -287,7 +304,8 @@ env: POSTGRES_HOST: "{{ .Release.Name }}-postgresql" POSTGRES_PORT: *pgPort POSTGRES_USER: *pgUser - POSTGRES_PASSWORD: *pgPass + # POSTGRES_PASSWORD is a secret — defined under env.secrets, not here in the + # (world-readable) ConfigMap. # Embedder EMBEDDER_MODEL_NAME: *embedderModel @@ -331,3 +349,6 @@ env: TRANSCRIBER_API_KEY: "EMPTY" AUTH_TOKEN: "sk-xxxx" # API KEY for OpenRAG HF_TOKEN: "hf_xxxx" # HuggingFace token + # DB password lives in the Secret (not the ConfigMap). Reuses the same + # value as postgresql.auth.password via the *pgPass anchor. + POSTGRES_PASSWORD: *pgPass diff --git a/cluster.yaml b/cluster.yaml index 066c9c22c..dbfe699e9 100644 --- a/cluster.yaml +++ b/cluster.yaml @@ -20,7 +20,8 @@ auth: head_start_ray_commands: - uv run ray stop - - uv run ray start --head --dashboard-host 0.0.0.0 --dashboard-port ${RAY_DASHBOARD_PORT:-8265} --node-ip-address ${HEAD_NODE_IP} --autoscaling-config=~/ray_bootstrap_config.yaml + # Dashboard is unauthenticated (CVE-2023-48022); bind to localhost, override with RAY_DASHBOARD_HOST. + - uv run ray start --head --dashboard-host ${RAY_DASHBOARD_HOST:-127.0.0.1} --dashboard-port ${RAY_DASHBOARD_PORT:-8265} --node-ip-address ${HEAD_NODE_IP} --autoscaling-config=~/ray_bootstrap_config.yaml worker_start_ray_commands: - uv run ray stop - uv run ray start --address ${HEAD_NODE_IP:-10.0.0.1}:6379 \ No newline at end of file diff --git a/conf/config.yaml b/conf/config.yaml index 393c0dfab..fc52ea267 100644 --- a/conf/config.yaml +++ b/conf/config.yaml @@ -64,7 +64,8 @@ rdb: host: rdb port: 5432 user: root - password: "root_password" + # No default; set via the POSTGRES_PASSWORD env var. + password: "" default_file_quota: -1 # --- Reranker --- @@ -90,8 +91,9 @@ map_reduce: # --- Logging --- # Env: LOG_LEVEL +# INFO by default; DEBUG persists user queries and request data to logs. verbose: - level: DEBUG + level: INFO # --- Server --- # Env: PREFERRED_URL_SCHEME @@ -126,9 +128,17 @@ prompts: loader: # Env: IMAGE_CAPTIONING, IMAGE_CAPTIONING_URL, SAVE_MARKDOWN image_captioning: true - image_captioning_url: true + # Off by default (captioning remote image URLs is an SSRF surface). When on, + # fetches are SSRF-guarded and proxied in-process. See loaders/base.py. + image_captioning_url: false save_markdown: false + # Bound fan-out/recursion/page counts during ingestion (parser-bomb guard). + max_attachments: 50 # attachments processed per .eml + max_eml_depth: 3 # nested .eml-in-.eml recursion depth + max_archive_entries: 2000 # embedded parts iterated per docx/pptx (zip fan-out) + max_pdf_pages: 2000 # pages processed per PDF + mimetypes: text/plain: .txt text/markdown: .md diff --git a/docker-compose.yaml b/docker-compose.yaml index 2bd528375..021c3be4d 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -5,7 +5,7 @@ include: - ${TRANSCRIBER_COMPOSE:-extern/dummy.yaml} x-openrag: &openrag_template - image: linagoraai/openrag:latest + image: linagoraai/openrag:1.1.11 build: context: . dockerfile: Dockerfile @@ -22,9 +22,10 @@ x-openrag: &openrag_template - ${DATA_VOLUME:-./data}:/app/data - ./i8n:/app/openrag/.chainlit/translations - ${MODEL_WEIGHTS_VOLUME:-~/.cache/huggingface}:/app/model_weights # Model weights for RAG - - ./openrag:/app/openrag # For dev mode + # Dev only: mounts source over the image. Keep off in production. + # - ./openrag:/app/openrag # For dev mode - /$SHARED_ENV:/ray_mount/.env # Shared environment variables - - ./logs:/app/logs # For dev mode + - ./logs:/app/logs ports: - ${APP_PORT:-8080}:${APP_iPORT:-8080} # Bind the Ray dashboard to localhost only: it has no authentication and @@ -75,7 +76,7 @@ x-vllm: &vllm_template services: # OpenRAG Indexer UI indexer-ui: - image: linagoraai/indexer-ui:latest + image: linagoraai/indexer-ui:1.1.11 build: context: ./extern/indexer-ui dockerfile: Dockerfile @@ -135,7 +136,7 @@ services: rdb: image: postgres:15 environment: - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-root_password} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in your .env} - POSTGRES_USER=${POSTGRES_USER:-root} volumes: - ${DB_VOLUME:-./db}:/var/lib/postgresql/data diff --git a/docs/assets/compose_ollama_cpu.yaml b/docs/assets/compose_ollama_cpu.yaml index 92d0ebead..e80bd6063 100644 --- a/docs/assets/compose_ollama_cpu.yaml +++ b/docs/assets/compose_ollama_cpu.yaml @@ -7,7 +7,8 @@ x-openrag: &openrag_template - ./ray_mount/logs:/app/logs ports: - 8090:8080 - - 8265:8265 # Disable when in cluster mode + # Localhost only: Ray dashboard/Jobs API is unauthenticated (CVE-2023-48022). Disable when in cluster mode + - 127.0.0.1:${RAY_DASHBOARD_PORT:-8265}:8265 networks: default: aliases: @@ -16,7 +17,7 @@ x-openrag: &openrag_template - .env environment: - APP_PORT=8090 - - AUTH_TOKEN=OpenRAG + - AUTH_TOKEN=${AUTH_TOKEN:?Set a strong AUTH_TOKEN in your .env} - RERANKER_ENABLED=false - MARKER_MAX_PROCESSES=1 - INDEXERUI_COMPOSE_FILE=true # Does not serve any purpose but needs to be enabled until PR is merged @@ -38,7 +39,7 @@ services: rdb: image: postgres:15 environment: - - POSTGRES_PASSWORD=root + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in your .env} - POSTGRES_USER=root volumes: - ./db:/var/lib/postgresql/data @@ -72,8 +73,8 @@ services: minio: image: minio/minio:RELEASE.2023-03-20T20-16-18Z environment: - MINIO_ACCESS_KEY: minioadmin - MINIO_SECRET_KEY: minioadmin + MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:?Set MINIO_ACCESS_KEY in your .env} + MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:?Set MINIO_SECRET_KEY in your .env} volumes: - ./volumes/minio:/minio_data command: minio server /minio_data --console-address ":9001" diff --git a/entrypoint.sh b/entrypoint.sh index 6d1e3e509..d2ce0bc5d 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,13 +1,20 @@ #!/bin/bash -ENV_ARG="" +ENV_ARGS=() if [[ -n "${SHARED_ENV}" ]]; then - ENV_ARG="--env-file=${SHARED_ENV}" + ENV_ARGS+=("--env-file=${SHARED_ENV}") fi if [[ "${ENABLE_RAY_SERVE}" == "true" ]]; then echo "🔁 Starting with Ray Serve..." - uv run $ENV_ARG api.py + uv run "${ENV_ARGS[@]}" api.py else echo "🚀 Starting with Uvicorn..." - uv run --no-dev $ENV_ARG uvicorn api:app --host 0.0.0.0 --port ${APP_iPORT:-8080} --reload --workers ${API_NUM_WORKERS:-1} + # --reload is dev-only (set UVICORN_RELOAD=true) and needs a single worker. + RELOAD_ARGS=() + WORKERS="${API_NUM_WORKERS:-1}" + if [[ "${UVICORN_RELOAD}" == "true" ]]; then + RELOAD_ARGS+=("--reload") + WORKERS="1" + fi + uv run --no-dev "${ENV_ARGS[@]}" uvicorn api:app --host 0.0.0.0 --port "${APP_iPORT:-8080}" "${RELOAD_ARGS[@]}" --workers "${WORKERS}" fi diff --git a/openrag/api.py b/openrag/api.py index a010a34eb..9f7bd2f1f 100644 --- a/openrag/api.py +++ b/openrag/api.py @@ -15,7 +15,9 @@ from fastapi.openapi.utils import get_openapi from fastapi.responses import JSONResponse, RedirectResponse -ray.init(dashboard_host="0.0.0.0") +# Bind the Ray dashboard to localhost; it's unauthenticated (CVE-2023-48022). +# Override with RAY_DASHBOARD_HOST (e.g. behind an auth proxy). +ray.init(dashboard_host=os.environ.get("RAY_DASHBOARD_HOST", "127.0.0.1")) # Apply noqa: E402 to ignore "module level import not at top of file" cause ray.init has to be called first @@ -23,6 +25,7 @@ from components.auth.middleware import AuthMiddleware +from components.rate_limit import RateLimitMiddleware from routers.actors import router as actors_router from routers.auth import router as auth_router from routers.download import router as download_router @@ -213,7 +216,9 @@ async def dispatch(self, request: Request, call_next): return response -# Register middlewares (order matters - last added runs first) +# Order matters (last added = outermost). RateLimitMiddleware is added first so +# it runs after AuthMiddleware sets request.state.user and can key on the user. +app.add_middleware(RateLimitMiddleware) app.add_middleware(AuthMiddleware, get_vectordb=get_vectordb) app.add_middleware(TokenRedactingMiddleware) app.add_middleware(MonitoringMiddleware) diff --git a/openrag/app_front.py b/openrag/app_front.py index 8816ebf5f..6b484fe4a 100644 --- a/openrag/app_front.py +++ b/openrag/app_front.py @@ -23,6 +23,32 @@ # Chainlit authentication CHAINLIT_AUTH_SECRET = os.environ.get("CHAINLIT_AUTH_SECRET") +_DEV_AUTH_SECRET = "default_secret_for_openrag_ui" + + +def _ensure_chainlit_auth_secret() -> None: + """Require CHAINLIT_AUTH_SECRET so UI session cookies can't be forged. + + Fail if it's unset; the built-in default is only allowed with ALLOW_NO_AUTH + (dev). + """ + if CHAINLIT_AUTH_SECRET: + return + if os.environ.get("ALLOW_NO_AUTH", "").strip().lower() == "true": + logger.warning( + "CHAINLIT_AUTH_SECRET is unset; using an insecure built-in default " + "because ALLOW_NO_AUTH=true. DEV ONLY — UI sessions are forgeable." + ) + os.environ["CHAINLIT_AUTH_SECRET"] = _DEV_AUTH_SECRET + return + raise RuntimeError( + "CHAINLIT_AUTH_SECRET is not set. Generate one with " + '`uv run chainlit create-secret` (or `python -c "import secrets; ' + 'print(secrets.token_hex(32))"`) and set it in the environment. ' + "To run insecurely in development only, set ALLOW_NO_AUTH=true." + ) + + # Application internal URL (used to call the API from Chainlit) port = os.environ.get("APP_iPORT", "8080") INTERNAL_BASE_URL = f"http://localhost:{port}" # Default fallback URL @@ -101,11 +127,7 @@ async def on_chat_resume(thread): if AUTH_TOKEN and AUTH_MODE != "oidc": - if not CHAINLIT_AUTH_SECRET: - # logger.warning( - # "`CHAINLIT_AUTH_SECRET` is not set a default value will be used. Not recommended for production." - # ) - os.environ["CHAINLIT_AUTH_SECRET"] = "default_secret_for_openrag_ui" # Set default value + _ensure_chainlit_auth_secret() @cl.password_auth_callback async def auth_callback(username: str, password: str): @@ -136,8 +158,7 @@ async def auth_callback(username: str, password: str): return None elif AUTH_MODE == "oidc": - if not CHAINLIT_AUTH_SECRET: - os.environ["CHAINLIT_AUTH_SECRET"] = "default_secret_for_openrag_ui" + _ensure_chainlit_auth_secret() @cl.header_auth_callback async def header_auth_callback(headers: dict) -> cl.User | None: @@ -257,7 +278,10 @@ async def _format_sources(metadata_sources, only_txt=False, api_key=None): filename = Path(s["filename"]) file_url = s["file_url"] file_url = file_url.replace(INTERNAL_BASE_URL, external_url) # put the correct base url - file_url = f"{file_url}?token={api_key}" # add token for authentication + # Don't put the token in the URL. In OIDC mode the browser sends the + # session cookie; in token mode the query param is the only option. + if AUTH_MODE != "oidc": + file_url = f"{file_url}?token={api_key}" page = s["page"] source_name = f"{filename}" + ( f" (page: {page})" if filename.suffix in [".pdf", ".pptx", ".docx", ".doc"] else "" diff --git a/openrag/components/auth/oidc_client.py b/openrag/components/auth/oidc_client.py index 50b449b9e..06b95303a 100644 --- a/openrag/components/auth/oidc_client.py +++ b/openrag/components/auth/oidc_client.py @@ -27,6 +27,9 @@ from authlib.jose import JsonWebKey, JsonWebToken from authlib.jose.errors import JoseError +# Clock-skew tolerance (seconds) for exp/nbf checks. +_CLOCK_SKEW_LEEWAY = 60 + @dataclass class TokenBundle: @@ -50,6 +53,7 @@ class LogoutTokenClaims: sid: str | None iat: int jti: str | None + exp: int = 0 class OIDCClient: @@ -291,9 +295,14 @@ async def _verify_id_token(self, token: str, *, expected_nonce: str | None) -> d if "exp" not in decoded: raise ValueError("ID token missing exp claim") - if int(decoded["exp"]) < now: + # Allow clock-skew leeway on the expiry check. + if int(decoded["exp"]) < now - _CLOCK_SKEW_LEEWAY: raise ValueError("ID token has expired") + # Reject not-yet-valid tokens (nbf), if present. + if "nbf" in decoded and int(decoded["nbf"]) > now + _CLOCK_SKEW_LEEWAY: + raise ValueError("ID token not yet valid (nbf in the future)") + if "iat" not in decoded: raise ValueError("ID token missing iat claim") @@ -345,8 +354,13 @@ async def verify_logout_token(self, token: str) -> LogoutTokenClaims: if "iat" not in decoded: raise ValueError("logout_token missing iat claim") - if int(decoded.get("exp", now + 1)) < now: + # exp and jti are required by the spec (jti is used for replay detection). + if "exp" not in decoded: + raise ValueError("logout_token missing exp claim") + if int(decoded["exp"]) < now - _CLOCK_SKEW_LEEWAY: raise ValueError("logout_token has expired") + if not decoded.get("jti"): + raise ValueError("logout_token missing jti claim") events = decoded.get("events") or {} if "http://schemas.openid.net/event/backchannel-logout" not in events: @@ -365,6 +379,7 @@ async def verify_logout_token(self, token: str) -> LogoutTokenClaims: sid=decoded.get("sid"), iat=int(decoded["iat"]), jti=decoded.get("jti"), + exp=int(decoded["exp"]), ) # ------------------------------------------------------------------ diff --git a/openrag/components/auth/test_oidc_client.py b/openrag/components/auth/test_oidc_client.py index 8a2e79198..33e4d781f 100644 --- a/openrag/components/auth/test_oidc_client.py +++ b/openrag/components/auth/test_oidc_client.py @@ -85,6 +85,7 @@ def _logout_token_payload( "iss": ISSUER, "aud": CLIENT_ID, "iat": now, + "exp": now + 120, "jti": "logout-jti-001", "events": {"http://schemas.openid.net/event/backchannel-logout": {}}, } @@ -385,3 +386,35 @@ async def test_missing_sub_and_sid_raises(self, client): token = _sign_jwt(_logout_token_payload(sub=None, sid=None)) with pytest.raises(ValueError, match="sub or sid"): await client.verify_logout_token(token) + + @pytest.mark.asyncio + async def test_missing_exp_raises(self, client): + _setup_discovery(client._mock_router) + _setup_jwks(client._mock_router) + + payload = _logout_token_payload() + del payload["exp"] + token = _sign_jwt(payload) + with pytest.raises(ValueError, match="exp"): + await client.verify_logout_token(token) + + @pytest.mark.asyncio + async def test_expired_logout_token_raises(self, client): + _setup_discovery(client._mock_router) + _setup_jwks(client._mock_router) + + # Beyond the clock-skew leeway so it is unambiguously expired. + token = _sign_jwt(_logout_token_payload(extra={"exp": int(time.time()) - 300})) + with pytest.raises(ValueError, match="expired"): + await client.verify_logout_token(token) + + @pytest.mark.asyncio + async def test_missing_jti_raises(self, client): + _setup_discovery(client._mock_router) + _setup_jwks(client._mock_router) + + payload = _logout_token_payload() + del payload["jti"] + token = _sign_jwt(payload) + with pytest.raises(ValueError, match="jti"): + await client.verify_logout_token(token) diff --git a/openrag/components/indexer/loaders/base.py b/openrag/components/indexer/loaders/base.py index 70f05e394..90f6b4b72 100644 --- a/openrag/components/indexer/loaders/base.py +++ b/openrag/components/indexer/loaders/base.py @@ -5,6 +5,7 @@ from io import BytesIO from pathlib import Path +import httpx from components.prompts import IMAGE_DESCRIBER from components.utils import get_vlm_semaphore, load_config from langchain_core.messages import HumanMessage @@ -14,6 +15,7 @@ from tqdm.asyncio import tqdm from utils.external_resource_errors import is_external_resource_error from utils.logger import get_logger +from utils.ssrf import guard_request, is_blocked_url_literal logger = get_logger() config = load_config() @@ -33,6 +35,9 @@ class BaseLoader(ABC): HTTP_IMAGE_PATTERN = re.compile(r"!\[(.*?)\]\((https?://[^)]+)\)") DATA_URI_IMAGE_PATTERN = re.compile(r"!\[(.*?)\]\((data:image/[^;]+;base64,[^)]+)\)") MIN_IMAGE_PIXELS = 784 # Qwen2.5-VL min_pixels threshold + # Cap remote image fetches to bound memory use. + MAX_REMOTE_IMAGE_BYTES = 20 * 1024 * 1024 + REMOTE_IMAGE_TIMEOUT = 10.0 def __init__(self, **kwargs) -> None: self.page_sep = "[PAGE_SEP]" @@ -85,6 +90,45 @@ def _is_data_uri(self, data: str) -> bool: """Check if string is a data URI.""" return isinstance(data, str) and data.startswith("data:image/") + async def _fetch_remote_image_as_data_uri(self, url: str) -> str | None: + """Fetch a remote image URL with SSRF protection, return it as a data URI. + + Returns None if the URL is unsafe, unreachable or not an image. We fetch + the image ourselves (rejecting non-global hosts and redirects) and pass + only the bytes to the VLM, so a poisoned document can't make the VLM hit + an internal URL. + """ + if is_blocked_url_literal(url): + logger.warning("Blocked non-global image URL for captioning", url=url) + return None + try: + timeout = httpx.Timeout(self.REMOTE_IMAGE_TIMEOUT) + async with httpx.AsyncClient( + timeout=timeout, + follow_redirects=False, + event_hooks={"request": [guard_request]}, + ) as client: + # Stream so we stop once the size cap is hit. + async with client.stream("GET", url) as resp: + resp.raise_for_status() + content_type = resp.headers.get("content-type", "") + if not content_type.startswith("image/"): + logger.warning("Remote image URL is not an image", url=url, content_type=content_type) + return None + buf = bytearray() + async for chunk in resp.aiter_bytes(): + buf.extend(chunk) + if len(buf) > self.MAX_REMOTE_IMAGE_BYTES: + logger.warning("Remote image exceeds size cap", url=url, size=len(buf)) + return None + data = bytes(buf) + except Exception as e: + logger.warning("Failed to fetch remote image for captioning", url=url, error=str(e)[:200]) + return None + + mime = content_type.split(";", 1)[0].strip() + return f"data:{mime};base64,{base64.b64encode(data).decode()}" + async def get_image_description( self, image_data: Image.Image | str, @@ -119,9 +163,11 @@ async def get_image_description( image_url = f"data:image/png;base64,{img_b64}" elif self._is_http_url(image_data): - # Handle HTTP/HTTPS URL - image_url = image_data - logger.debug(f"Processing HTTP URL: {image_data}") + # Fetch the image ourselves (SSRF-guarded) and send a data URI. + image_url = await self._fetch_remote_image_as_data_uri(image_data) + if not image_url: + return "\n\nImage URL unavailable or blocked\n\n" + logger.debug(f"Fetched HTTP image URL in-process: {image_data}") elif self._is_data_uri(image_data): # Handle data URI - use as-is diff --git a/openrag/components/indexer/loaders/docx.py b/openrag/components/indexer/loaders/docx.py index 615f58abf..7d35a402f 100644 --- a/openrag/components/indexer/loaders/docx.py +++ b/openrag/components/indexer/loaders/docx.py @@ -82,6 +82,12 @@ def _fallback_extract_text(self, file_path) -> str: return "\n\n".join(p.text for p in doc.paragraphs if p.text.strip()) def get_images_from_zip(self, input_file): + # Cap the number of media entries and the size of each (zip-bomb guard). + max_entries, max_entry_bytes = 2000, 100 * 1024 * 1024 + loader_cfg = getattr(getattr(self, "config", None), "loader", None) + if loader_cfg is not None: + max_entries = int(loader_cfg.get("max_archive_entries", max_entries)) + max_entry_bytes = int(loader_cfg.get("max_archive_entry_bytes", max_entry_bytes)) try: docx = zipfile.ZipFile(input_file, "r") except zipfile.BadZipFile: @@ -93,13 +99,26 @@ def get_images_from_zip(self, input_file): image_files = [f for f in file_names if f.startswith("word/media/")] if not image_files: return [] + if len(image_files) > max_entries: + logger.warning( + "Capping embedded media extraction", path=str(input_file), found=len(image_files), cap=max_entries + ) + image_files = image_files[:max_entries] - images_not_in_order, order = [], [] - - # the images got from the original file is not in the right order - # but the target_ref contains the position of the image in the document + # Map position (from filename) -> image; a dict avoids allocating a + # list sized by an attacker-controlled index. + by_order: dict[int, Image.Image] = {} for image_file in image_files: + info = docx.getinfo(image_file) + if info.file_size > max_entry_bytes: + logger.warning( + "Skipping oversized embedded media entry", + entry=image_file, + size=info.file_size, + cap=max_entry_bytes, + ) + continue image_data = docx.read(image_file) image_extension = image_file.split(".")[-1].lower() try: @@ -110,15 +129,24 @@ def get_images_from_zip(self, input_file): logger.warning(f"Skipping unsupported media file {image_file}: {e}") continue - images_not_in_order.append(image) - order.append(order_num) + # order_num (from the filename) is 1-based; skip bad values that + # would mis-index images[pos-1]. + if order_num < 1: + logger.warning(f"Skipping media file with non-positive index: {image_file}") + continue + + by_order[order_num] = image - if not images_not_in_order: + if not by_order: return [] - # Reorder images by their original position in the document - max_order = max(order) - images = [None] * max_order - for i, pos in enumerate(order): - images[pos - 1] = images_not_in_order[i] - return images + # Order by position, keeping None gaps for skipped media (caption + # alignment relies on it). Only build the positional list when the max + # index is within the cap; otherwise return a compact ordered list. + max_order = max(by_order) + if max_order <= max_entries: + images = [None] * max_order + for pos, img in by_order.items(): + images[pos - 1] = img + return images + return [by_order[k] for k in sorted(by_order)] diff --git a/openrag/components/indexer/loaders/eml_loader.py b/openrag/components/indexer/loaders/eml_loader.py index 96a5ee1e2..190857f4d 100644 --- a/openrag/components/indexer/loaders/eml_loader.py +++ b/openrag/components/indexer/loaders/eml_loader.py @@ -26,6 +26,10 @@ def __init__(self, **kwargs): self.kwargs = kwargs # Get available loaders for processing attachments self.loader_classes = get_loader_classes(config=self.config) + # Parser-bomb caps (a nested .eml recurses back into this loader). + self.eml_depth = int(kwargs.get("eml_depth", 0)) + self.max_attachments = int(self.config.loader.get("max_attachments", 50)) + self.max_eml_depth = int(self.config.loader.get("max_eml_depth", 3)) async def aload_document(self, file_path, metadata: dict | None = None, save_markdown: bool = False): try: @@ -66,6 +70,9 @@ async def aload_document(self, file_path, metadata: dict | None = None, save_mar # Handle attachments filename = part.get_filename() if filename: + # Cap attachments before decoding (don't decode the overflow). + if len(email_data["attachment"]) >= self.max_attachments: + continue payload = part.get_payload(decode=True) if payload: attachment_info = { @@ -126,15 +133,23 @@ async def aload_document(self, file_path, metadata: dict | None = None, save_mar # Check if we have a loader for this file type loader_cls = self.loader_classes.get(file_ext) + # Stop nested .eml recursion past the depth limit. + if file_ext == ".eml" and self.eml_depth >= self.max_eml_depth: + attachments_text += "Nested email attachment skipped (max depth reached)\n---\n" + continue + if loader_cls: # Save attachment to temporary file with tempfile.NamedTemporaryFile(suffix=file_ext, delete=False) as temp_file: temp_file.write(attachment["raw"]) temp_file_path = temp_file.name + # Bump eml depth for nested loaders. + child_kwargs = {**self.kwargs, "eml_depth": self.eml_depth + 1} + try: # Use appropriate loader to process attachment - loader = loader_cls(**self.kwargs) + loader = loader_cls(**child_kwargs) attachment_doc = await loader.aload_document( temp_file_path, metadata={"source": f"attachment:{filename}"}, diff --git a/openrag/components/indexer/loaders/image.py b/openrag/components/indexer/loaders/image.py index a1356c802..d3e41f883 100644 --- a/openrag/components/indexer/loaders/image.py +++ b/openrag/components/indexer/loaders/image.py @@ -25,7 +25,9 @@ async def aload_document(self, file_path, metadata=None, save_markdown=False): try: # Handle SVG files by converting to PNG first if path.suffix.lower() == ".svg": - png_data = cairosvg.svg2png(url=str(path)) + # unsafe=False (the default) blocks external fetches and XML + # entities in untrusted SVGs (SSRF/XXE); set it explicitly. + png_data = cairosvg.svg2png(url=str(path), unsafe=False) img = Image.open(BytesIO(png_data)) else: img = Image.open(path) diff --git a/openrag/components/indexer/loaders/pdf_loaders/marker.py b/openrag/components/indexer/loaders/pdf_loaders/marker.py index 62e136dc1..1b426349d 100644 --- a/openrag/components/indexer/loaders/pdf_loaders/marker.py +++ b/openrag/components/indexer/loaders/pdf_loaders/marker.py @@ -261,16 +261,30 @@ async def attempt(i: int): async def process_pdf(self, file_path: str): chunk_size = self.config.loader.marker_chunk_size + # Parser-bomb cap: never process more than max_pdf_pages from one file. + max_pages = int(self.config.loader.get("max_pdf_pages", 2000)) + + total_pages = self._get_page_count(file_path) + capped = max_pages > 0 and total_pages > max_pages + if capped: + self.logger.warning( + f"PDF has {total_pages} pages; processing only the first {max_pages} (max_pdf_pages cap)" + ) + page_count = min(total_pages, max_pages) if max_pages > 0 else total_pages if chunk_size <= 0: - return await self._process_chunk(file_path, page_range=None, label="(all pages)") + # When capped, restrict to the first page_count pages instead of all. + page_range = list(range(page_count)) if capped else None + label = f"(first {page_count}p)" if capped else "(all pages)" + return await self._process_chunk(file_path, page_range=page_range, label=label) - page_count = self._get_page_count(file_path) chunks = self._create_chunks(page_count, chunk_size) if len(chunks) == 1: page_range, label = chunks[0] - return await self._process_chunk(file_path, page_range=None, label=label) + # When capped, pass the explicit range (None would process all pages + # and bypass max_pdf_pages); uncapped, None means "all". + return await self._process_chunk(file_path, page_range=(page_range if capped else None), label=label) self.logger.info( f"Splitting {page_count}-page PDF into {len(chunks)} chunks of ~{chunk_size} pages for parallel processing" diff --git a/openrag/components/indexer/loaders/pptx_loader.py b/openrag/components/indexer/loaders/pptx_loader.py index 69fa45be5..4fcbc19a5 100644 --- a/openrag/components/indexer/loaders/pptx_loader.py +++ b/openrag/components/indexer/loaders/pptx_loader.py @@ -19,9 +19,18 @@ class PPTXConverter: https://github.com/microsoft/markitdown/blob/main/packages/markitdown/src/markitdown/converters/_pptx_converter.py """ - def __init__(self, image_placeholder=r"", page_separator: str = "[PAGE_SEP]"): + def __init__( + self, + image_placeholder=r"", + page_separator: str = "[PAGE_SEP]", + max_slides: int = 2000, + max_images: int = 2000, + ): self.image_placeholder = image_placeholder self.page_separator = page_separator + # Parser-bomb caps: bound slides processed and images decoded into memory. + self.max_slides = max_slides + self.max_images = max_images def convert(self, local_path): md_content = "" @@ -30,12 +39,16 @@ def convert(self, local_path): images_list = [] for slide in presentation.slides: slide_num += 1 + if slide_num > self.max_slides: + logger.warning("Capping slide processing", cap=self.max_slides) + break title = slide.shapes.title for shape in slide.shapes: if self._is_picture(shape): - images_list.append(Image.open(BytesIO(shape.image.blob))) - md_content += self.image_placeholder + if len(images_list) < self.max_images: + images_list.append(Image.open(BytesIO(shape.image.blob))) + md_content += self.image_placeholder # Tables if self._is_table(shape): @@ -137,7 +150,13 @@ class PPTXLoader(BaseLoader): def __init__(self, **kwargs) -> None: super().__init__(**kwargs) self.image_placeholder = r"" - self.converter = PPTXConverter(image_placeholder=self.image_placeholder, page_separator=self.page_sep) + max_entries = int(self.config.loader.get("max_archive_entries", 2000)) + self.converter = PPTXConverter( + image_placeholder=self.image_placeholder, + page_separator=self.page_sep, + max_slides=max_entries, + max_images=max_entries, + ) async def aload_document(self, file_path, metadata=None, save_markdown=False): md_content, imgs = self.converter.convert(local_path=file_path) diff --git a/openrag/components/indexer/utils/test_text_sanitizer.py b/openrag/components/indexer/utils/test_text_sanitizer.py index d26f2be22..8df30d82b 100644 --- a/openrag/components/indexer/utils/test_text_sanitizer.py +++ b/openrag/components/indexer/utils/test_text_sanitizer.py @@ -4,11 +4,42 @@ from .text_sanitizer import ( clean_markdown_table_spacing, + neutralize_prompt_control_tokens, sanitize_extracted_text, sanitize_text, ) +class TestNeutralizePromptControlTokens: + """Defang RAG control tokens embedded in untrusted document/web text.""" + + def test_source_block_marker_defanged(self): + out = neutralize_prompt_control_tokens("real text\n[Source 99]\nfake source body") + assert "[Source 99]" not in out + assert "(Source 99]" in out # opening bracket neutralized + + def test_bracketed_sources_tag_defanged(self): + out = neutralize_prompt_control_tokens("answer [Sources: 1, 2]") + assert "[Sources: 1, 2]" not in out + + def test_unbracketed_sources_tag_line_defanged(self): + # The answer parser also strips an unbracketed "Sources: 1, 2" at line end. + out = neutralize_prompt_control_tokens("blah\nSources: 1, 2") + assert "Sources: 1, 2" not in out + + def test_separator_run_capped(self): + out = neutralize_prompt_control_tokens("a\n----------\n\nb") + assert "----------" not in out + assert "---" in out + + def test_benign_text_preserved(self): + text = "The function returns a list. See the table below." + assert neutralize_prompt_control_tokens(text) == text + + def test_empty_text(self): + assert neutralize_prompt_control_tokens("") == "" + + class TestSanitizeText: """Test suite for sanitize_text function.""" diff --git a/openrag/components/indexer/utils/text_sanitizer.py b/openrag/components/indexer/utils/text_sanitizer.py index 304a094cb..4a308ce23 100644 --- a/openrag/components/indexer/utils/text_sanitizer.py +++ b/openrag/components/indexer/utils/text_sanitizer.py @@ -95,6 +95,27 @@ def sanitize_text( return text +# Control tokens a document could embed to forge citations or source boundaries: +# "[Source N]" (block marker), "[Sources: 1, 3]" / "Sources: 1, 3" (answer tag), +# and "----------" (separator). Neutralize them in untrusted text. +_INJECT_SOURCE_BLOCK_RE = re.compile(r"\[\s*(sources?)\b", re.IGNORECASE) +_INJECT_SOURCES_TAG_RE = re.compile(r"(?im)^([ \t]*)(sources?)(\s*:\s*)(\[?[\d,\s]+\]?)[ \t]*$") +_INJECT_SEPARATOR_RE = re.compile(r"-{4,}") + + +def neutralize_prompt_control_tokens(text: str) -> str: + """Defang RAG control tokens in untrusted text so they can't fake markers.""" + if not text: + return text + # "[Source...]" / "[Sources...]" -> open paren so it can't start a marker. + text = _INJECT_SOURCE_BLOCK_RE.sub(r"(\1", text) + # Break the unbracketed "Sources: 1, 2" line form the parser also matches. + text = _INJECT_SOURCES_TAG_RE.sub(r"\1\2 \4", text) + # Cap long hyphen runs so they can't reproduce the separator. + text = _INJECT_SEPARATOR_RE.sub("---", text) + return text + + def clean_markdown_table_spacing(markdown_table: str) -> str: """ Normalize spacing inside a markdown table: diff --git a/openrag/components/indexer/vectordb/utils.py b/openrag/components/indexer/vectordb/utils.py index 77d6112d4..7bc0983c9 100644 --- a/openrag/components/indexer/vectordb/utils.py +++ b/openrag/components/indexer/vectordb/utils.py @@ -492,11 +492,23 @@ def update_partition_member_role(self, partition: str, user_id: int, new_role: s s.commit() return True - def create_partition(self, partition: str, user_id: int): + def create_partition(self, partition: str, user_id: int, max_owned: int | None = None) -> str: + """Create a partition owned by ``user_id``. + + If ``max_owned`` is >= 0, the owner-count check and insert run in the same + session (atomic, since the Vectordb actor serializes calls). None or a + negative value skips the cap. Returns "created", "exists" or + "limit_exceeded". + """ with self.Session() as s: if s.query(Partition).filter(Partition.partition == partition).first(): self.logger.warning(f"Partition '{partition}' already exists.") - return + return "exists" + if max_owned is not None and max_owned >= 0: + owned = s.query(PartitionMembership).filter_by(user_id=user_id, role="owner").count() + if owned >= max_owned: + self.logger.warning(f"User {user_id} reached partition limit ({max_owned}).") + return "limit_exceeded" p = Partition(partition=partition) s.add(p) # Add creator as owner @@ -504,6 +516,7 @@ def create_partition(self, partition: str, user_id: int): s.add(m) s.commit() self.logger.info(f"Partition '{partition}' created by user_id {user_id}.") + return "created" def list_user_partitions(self, user_id: int): """Return full partition objects (to_dict) with role for a given user.""" diff --git a/openrag/components/indexer/vectordb/vectordb.py b/openrag/components/indexer/vectordb/vectordb.py index 2ea0aff28..30e091b26 100644 --- a/openrag/components/indexer/vectordb/vectordb.py +++ b/openrag/components/indexer/vectordb/vectordb.py @@ -644,28 +644,32 @@ async def async_search( async def get_surrounding_chunks(self, docs: list[Document]) -> list[Document]: existant_ids = {doc.metadata.get("_id") for doc in docs} - # Collect all prev/next section IDs - section_ids = [ - section_id + # Collect (section_id, partition) pairs. section_id is only unique within + # a partition, so the lookup MUST be scoped to the source doc's partition; + # otherwise a neighbouring section_id could resolve to another tenant's + # chunk (cross-tenant leak). Drop pairs with no partition rather than + # querying unscoped. + section_refs = [ + (section_id, doc.metadata.get("partition")) for doc in docs for section_id in [ doc.metadata.get("prev_section_id"), doc.metadata.get("next_section_id"), ] - if section_id is not None + if section_id is not None and doc.metadata.get("partition") ] - if not section_ids: + if not section_refs: return [] - # Query all sections in parallel + # Query all sections in parallel, each scoped to its partition. tasks = [ self._async_client.query( collection_name=self.collection_name, - filter=f"section_id == {section_id}", + filter=f"section_id == {section_id} and partition == '{partition}'", limit=1, ) - for section_id in section_ids + for section_id, partition in section_refs ] responses = await asyncio.gather(*tasks) @@ -1307,10 +1311,11 @@ async def update_partition_member_role(self, partition: str, user_id: int, new_r self.partition_file_manager.update_partition_member_role(partition, user_id, new_role) self.logger.info(f"User_id {user_id} role updated to '{new_role}' in partition '{partition}'.") - async def create_partition(self, partition: str, user_id: int): + async def create_partition(self, partition: str, user_id: int, max_owned: int | None = None) -> str: self._check_user_exists(user_id) - self.partition_file_manager.create_partition(partition, user_id) - self.logger.info(f"Partition '{partition}' created by user_id {user_id}.") + result = self.partition_file_manager.create_partition(partition, user_id, max_owned) + self.logger.info(f"create_partition '{partition}' by user_id {user_id}: {result}") + return result async def add_partition_member(self, partition: str, user_id: int, role: str): self._check_partition_exists(partition) diff --git a/openrag/components/pipeline.py b/openrag/components/pipeline.py index 8187392d0..a9faf1967 100644 --- a/openrag/components/pipeline.py +++ b/openrag/components/pipeline.py @@ -137,7 +137,6 @@ async def retrieve_docs( if not docs and milvus_filter and self.allow_filterless_fallback: logger.warning( "Temporal filter dropped: no documents matched, retrying without filter", - query=str(query.query), filter=milvus_filter, partition=partition, ) diff --git a/openrag/components/rate_limit.py b/openrag/components/rate_limit.py new file mode 100644 index 000000000..f93d1c3be --- /dev/null +++ b/openrag/components/rate_limit.py @@ -0,0 +1,86 @@ +"""Per-identity request rate limiting, tiered by path. + +Keyed on the authenticated user, or the client IP for unauthenticated paths. +Runs after AuthMiddleware (see api.py) so request.state.user is set. Limits are +per-worker; use a Redis storage to share them across workers. + +Env: RATE_LIMIT_ENABLED (true), RATE_LIMIT_DEFAULT (300/minute), +RATE_LIMIT_AUTH (20/minute, /auth/*), RATE_LIMIT_CHAT (60/minute, /v1/*). +""" + +import os +import time + +from limits import parse +from limits.aio.storage import MemoryStorage +from limits.aio.strategies import MovingWindowRateLimiter +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse +from utils.logger import get_logger + +logger = get_logger() + + +def _env_flag(name: str, default: bool) -> bool: + val = os.environ.get(name) + if val is None: + return default + return val.strip().lower() in ("1", "true", "yes", "on") + + +class RateLimitMiddleware(BaseHTTPMiddleware): + """Apply per-identity moving-window rate limits, tiered by path prefix.""" + + def __init__(self, app): + super().__init__(app) + self.enabled = _env_flag("RATE_LIMIT_ENABLED", True) + self._limiter = MovingWindowRateLimiter(MemoryStorage()) + self._default = parse(os.environ.get("RATE_LIMIT_DEFAULT", "300/minute")) + self._auth = parse(os.environ.get("RATE_LIMIT_AUTH", "20/minute")) + self._chat = parse(os.environ.get("RATE_LIMIT_CHAT", "60/minute")) + if self.enabled: + logger.info( + "Rate limiting enabled", + default=str(self._default), + auth=str(self._auth), + chat=str(self._chat), + ) + + def _limit_for(self, path: str): + if path.startswith("/auth/"): + return self._auth, "auth" + if path.startswith("/v1/"): + return self._chat, "chat" + return self._default, "default" + + @staticmethod + def _identity(request: Request) -> str: + # user is a dict set by AuthMiddleware; fall back to client IP. + user = getattr(request.state, "user", None) + user_id = user.get("id") if isinstance(user, dict) else None + if user_id is not None: + return f"user:{user_id}" + client = request.client + return f"ip:{client.host}" if client else "ip:unknown" + + async def dispatch(self, request: Request, call_next): + if not self.enabled: + return await call_next(request) + + path = request.url.path + limit, tier = self._limit_for(path) + identity = self._identity(request) + + # Key by tier so each tier has its own budget. + allowed = await self._limiter.hit(limit, tier, identity) + if not allowed: + stats = await self._limiter.get_window_stats(limit, tier, identity) + retry_after = max(1, int(stats.reset_time - time.time())) + logger.warning("Rate limit exceeded", path=path, tier=tier, identity=identity) + return JSONResponse( + status_code=429, + content={"detail": "Rate limit exceeded. Please retry later.", "extra": {}}, + headers={"Retry-After": str(retry_after)}, + ) + return await call_next(request) diff --git a/openrag/components/test_rate_limit.py b/openrag/components/test_rate_limit.py new file mode 100644 index 000000000..3022a8a4c --- /dev/null +++ b/openrag/components/test_rate_limit.py @@ -0,0 +1,82 @@ +"""Tests for the path-tiered rate limiting middleware.""" + +import pytest +from components.rate_limit import RateLimitMiddleware +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import PlainTextResponse +from starlette.routing import Route +from starlette.testclient import TestClient + + +def _make_request(user=None, host="1.2.3.4") -> Request: + scope = { + "type": "http", + "method": "GET", + "path": "/", + "headers": [], + "client": (host, 1234), + } + req = Request(scope) + if user is not None: + req.state.user = user + return req + + +def test_identity_keys_on_authenticated_user_dict(): + # request.state.user is a dict (set by AuthMiddleware); identity must key on + # its "id", not fall through to the client IP. + assert RateLimitMiddleware._identity(_make_request(user={"id": 7})) == "user:7" + + +def test_identity_falls_back_to_ip_when_unauthenticated(): + assert RateLimitMiddleware._identity(_make_request(user=None, host="9.9.9.9")) == "ip:9.9.9.9" + + +def _build_app(monkeypatch, **env): + for k, v in env.items(): + monkeypatch.setenv(k, v) + + async def ok(request: Request): + return PlainTextResponse("ok") + + app = Starlette(routes=[Route("/v1/chat", ok), Route("/auth/login", ok), Route("/other", ok)]) + app.add_middleware(RateLimitMiddleware) + return app + + +def test_allows_under_limit(monkeypatch): + app = _build_app(monkeypatch, RATE_LIMIT_CHAT="5/minute") + client = TestClient(app) + for _ in range(5): + assert client.get("/v1/chat").status_code == 200 + + +def test_blocks_over_limit_with_retry_after(monkeypatch): + app = _build_app(monkeypatch, RATE_LIMIT_CHAT="2/minute") + client = TestClient(app) + assert client.get("/v1/chat").status_code == 200 + assert client.get("/v1/chat").status_code == 200 + resp = client.get("/v1/chat") + assert resp.status_code == 429 + assert "Retry-After" in resp.headers + + +def test_tiers_have_independent_budgets(monkeypatch): + # Exhausting the chat tier must not affect the auth tier. + app = _build_app(monkeypatch, RATE_LIMIT_CHAT="1/minute", RATE_LIMIT_AUTH="1/minute") + client = TestClient(app) + assert client.get("/v1/chat").status_code == 200 + assert client.get("/v1/chat").status_code == 429 + assert client.get("/auth/login").status_code == 200 + + +def test_disabled_passes_through(monkeypatch): + app = _build_app(monkeypatch, RATE_LIMIT_ENABLED="false", RATE_LIMIT_CHAT="1/minute") + client = TestClient(app) + for _ in range(5): + assert client.get("/v1/chat").status_code == 200 + + +if __name__ == "__main__": + pytest.main([__file__, "-q"]) diff --git a/openrag/components/utils.py b/openrag/components/utils.py index c3e7ce8dd..238b9238b 100644 --- a/openrag/components/utils.py +++ b/openrag/components/utils.py @@ -6,7 +6,10 @@ from typing import ClassVar import ray -from components.indexer.utils.text_sanitizer import sanitize_text +from components.indexer.utils.text_sanitizer import ( + neutralize_prompt_control_tokens, + sanitize_text, +) from config import load_config from fast_langdetect import LangDetectConfig, LangDetector from langchain_core.documents.base import Document @@ -108,12 +111,14 @@ def format_context( for i, doc in enumerate(docs): prefix = f"[Source {len(reduced_docs) + 1}]\n" if number_sources else "" - n_tokens = _length_function(doc.page_content) + # Neutralize control tokens so the document can't forge source markers. + content = neutralize_prompt_control_tokens(doc.page_content) + n_tokens = _length_function(content) if prefix: n_tokens += _length_function(prefix) if total_tokens + n_tokens > max_context_tokens: break - reduced_docs.append(f"{prefix}{doc.page_content}") + reduced_docs.append(f"{prefix}{content}") included_indices.append(i) total_tokens += n_tokens @@ -149,8 +154,9 @@ def format_web_context( for i, result in enumerate(web_results): n = start_index + i - title = sanitize_text(result.title) - body = sanitize_text(result.content) if result.content else sanitize_text(result.snippet) + title = neutralize_prompt_control_tokens(sanitize_text(result.title)) + raw_body = result.content if result.content else result.snippet + body = neutralize_prompt_control_tokens(sanitize_text(raw_body)) block = f"[Source {n}]\n{title}\n{body}" block_tokens = _length_function(block) if total_tokens + block_tokens > max_tokens and parts: diff --git a/openrag/components/websearch/content_fetcher.py b/openrag/components/websearch/content_fetcher.py index de5695526..6dce048bf 100644 --- a/openrag/components/websearch/content_fetcher.py +++ b/openrag/components/websearch/content_fetcher.py @@ -1,13 +1,11 @@ import asyncio -import ipaddress -import socket -from urllib.parse import urlparse import httpx import lxml.html from components.websearch.base import WebResult from html_to_markdown import convert from utils.logger import get_logger +from utils.ssrf import guard_request, is_blocked_url_literal logger = get_logger() @@ -47,49 +45,9 @@ def _truncate(self, text: str) -> str: truncated = truncated[:last_space] return truncated.rstrip() + " [...]" - @staticmethod - def _is_loopback_url(url: str) -> bool: - """Block obviously-internal targets without a DNS lookup. - - Rejects non-HTTP(S) schemes and any literal IP that is not globally - routable (loopback, private, link-local, etc.). Hostnames are passed - through here and validated against their *resolved* IPs in the request - hook below. - """ - parsed = urlparse(url) - if parsed.scheme not in ("http", "https"): - return True - host = parsed.hostname or "" - if not host or host == "localhost": - return True - try: - return not ipaddress.ip_address(host).is_global - except ValueError: - return False # Regular hostname → checked after DNS resolution - - @staticmethod - async def _guard_request(request: httpx.Request) -> None: - """httpx request hook: block requests whose host resolves to a - non-global IP. Runs for the initial request and every redirect hop, - so a public hostname that resolves (or redirects) to an internal - address is rejected before the connection is used. - """ - host = request.url.host - if not host: - raise httpx.RequestError("Blocked request with no host", request=request) - try: - infos = await asyncio.to_thread(socket.getaddrinfo, host, None) - except socket.gaierror as e: - raise httpx.RequestError(f"DNS resolution failed for {host}", request=request) from e - for info in infos: - ip = info[4][0] - try: - addr = ipaddress.ip_address(ip) - except ValueError: - raise httpx.RequestError(f"Unparseable address for {host}", request=request) - if not addr.is_global: - logger.warning("Blocked SSRF attempt to non-global address", host=host, ip=ip) - raise httpx.RequestError(f"Blocked non-global address {ip} for {host}", request=request) + # SSRF guards are shared with the document image-URL captioner; see utils.ssrf. + _is_loopback_url = staticmethod(is_blocked_url_literal) + _guard_request = staticmethod(guard_request) async def _fetch_single(self, client: httpx.AsyncClient, url: str) -> str | None: """Fetch a single URL and extract text. Returns None on any failure.""" diff --git a/openrag/components/websearch/service.py b/openrag/components/websearch/service.py index 8ef0e93bd..6f15cdb99 100644 --- a/openrag/components/websearch/service.py +++ b/openrag/components/websearch/service.py @@ -23,7 +23,8 @@ async def search(self, query: str) -> list[WebResult]: try: results = await self.provider.search(query) if not results: - logger.warning("Web search returned zero results", query=query) + # Don't log the query text (PII). + logger.warning("Web search returned zero results") return results if self.content_fetcher: diff --git a/openrag/components/websearch/test_content_fetcher.py b/openrag/components/websearch/test_content_fetcher.py index 25921205a..b0f5072a8 100644 --- a/openrag/components/websearch/test_content_fetcher.py +++ b/openrag/components/websearch/test_content_fetcher.py @@ -97,7 +97,7 @@ async def test_guard_request_blocks_host_resolving_to_private_ip(self, fetcher, def fake_getaddrinfo(host, port, *args, **kwargs): return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("10.0.0.5", 0))] - monkeypatch.setattr("components.websearch.content_fetcher.socket.getaddrinfo", fake_getaddrinfo) + monkeypatch.setattr("utils.ssrf.socket.getaddrinfo", fake_getaddrinfo) req = httpx.Request("GET", "http://internal.example.com/") with pytest.raises(httpx.RequestError): await fetcher._guard_request(req) @@ -107,7 +107,7 @@ async def test_guard_request_allows_global_ip(self, fetcher, monkeypatch): def fake_getaddrinfo(host, port, *args, **kwargs): return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 0))] - monkeypatch.setattr("components.websearch.content_fetcher.socket.getaddrinfo", fake_getaddrinfo) + monkeypatch.setattr("utils.ssrf.socket.getaddrinfo", fake_getaddrinfo) req = httpx.Request("GET", "http://example.com/") await fetcher._guard_request(req) # must not raise diff --git a/openrag/models/openai.py b/openrag/models/openai.py index 88915c252..b22910626 100644 --- a/openrag/models/openai.py +++ b/openrag/models/openai.py @@ -41,13 +41,14 @@ class OpenAICompletionRequest(BaseModel): model: str | None = Field(None, description="model name") prompt: str - best_of: int | None = Field(1) + # Bound n/best_of: each multiplies generation cost. + best_of: int | None = Field(1, ge=1, le=8) echo: bool | None = Field(False) frequency_penalty: float | None = Field(0.0) logit_bias: dict | None = Field(None) logprobs: int | None = Field(None) max_tokens: int | None = Field(default_max_tokens) - n: int | None = Field(1) + n: int | None = Field(1, ge=1, le=8) presence_penalty: float | None = Field(0.0) seed: int | None = Field(None) stop: list[str] | None = Field(None) diff --git a/openrag/routers/auth.py b/openrag/routers/auth.py index f23fb8434..d5a09ca15 100644 --- a/openrag/routers/auth.py +++ b/openrag/routers/auth.py @@ -16,6 +16,7 @@ from __future__ import annotations import os +import time from datetime import datetime, timedelta from typing import Any from urllib.parse import urlencode, urlparse @@ -520,6 +521,24 @@ async def callback(request: Request, code: str | None = None, state: str | None # --------------------------------------------------------------------------- +# Seen logout-token jti -> exp, so a token is only consumed once. Per-worker; +# logout is idempotent so this is just defence-in-depth. +_seen_logout_jti: dict[str, int] = {} + + +def _logout_jti_is_replay(jti: str, exp: int) -> bool: + """Record a logout-token jti and report whether it was already seen.""" + now = int(time.time()) + # Prune expired entries to bound memory. + for old_jti, old_exp in list(_seen_logout_jti.items()): + if old_exp < now: + del _seen_logout_jti[old_jti] + if jti in _seen_logout_jti: + return True + _seen_logout_jti[jti] = exp + return False + + @router.post("/auth/backchannel-logout", include_in_schema=False) async def backchannel_logout(logout_token: str = Form(...)): """IdP-initiated logout per OIDC Back-Channel Logout spec. @@ -547,6 +566,15 @@ async def backchannel_logout(logout_token: str = Form(...)): headers={"Cache-Control": "no-store"}, ) + # Reject replays: a given logout token (jti) must only be processed once. + if claims.jti and _logout_jti_is_replay(claims.jti, claims.exp): + logger.warning(f"Replayed back-channel logout token ignored — jti={claims.jti!r}") + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={"error": "invalid_request", "error_description": "logout_token replayed"}, + headers={"Cache-Control": "no-store"}, + ) + if claims.sid: vdb = get_vectordb() count = await vdb.revoke_oidc_sessions_by_sid.remote(claims.sid) @@ -570,10 +598,32 @@ async def backchannel_logout(logout_token: str = Form(...)): # --------------------------------------------------------------------------- +def _is_csrf_safe_navigation(request: Request) -> bool: + """Block cross-site CSRF logout while allowing top-level navigation. + + Logout stays a GET (OIDC redirects to the IdP), so instead of requiring POST + we use Fetch Metadata: a cross-site non-navigation request (forged etc.) + is blocked; navigations and same-origin requests pass. Older browsers that + omit these headers are allowed. + """ + site = request.headers.get("sec-fetch-site") + mode = request.headers.get("sec-fetch-mode") + if site == "cross-site" and mode not in (None, "navigate"): + return False + return True + + @router.get("/auth/logout", include_in_schema=False) async def logout(request: Request): _require_oidc_mode() + if not _is_csrf_safe_navigation(request): + logger.warning("Blocked cross-site non-navigation request to /auth/logout (CSRF)") + return JSONResponse( + status_code=status.HTTP_403_FORBIDDEN, + content={"detail": "Cross-site logout requests are not allowed"}, + ) + vdb = get_vectordb() client: OIDCClient = get_oidc_client() diff --git a/openrag/routers/indexer.py b/openrag/routers/indexer.py index 0362a3491..5204c3c38 100644 --- a/openrag/routers/indexer.py +++ b/openrag/routers/indexer.py @@ -22,6 +22,7 @@ from .utils import ( check_user_file_quota, + current_user, current_user_partitions, ensure_partition_role, human_readable_size, @@ -147,10 +148,11 @@ async def add_file( try: file_path = await save_file_to_disk(file, save_dir, with_random_prefix=True) except Exception as e: + # Log details server-side; return a generic message (no paths/internals). logger.exception("Failed to save file to disk.", error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=str(e), + detail="Failed to save uploaded file.", ) metadata.update( @@ -483,6 +485,7 @@ async def get_task_error( task_id: str, task_state_manager=Depends(get_task_state_manager), task_details=Depends(require_task_owner), + user=Depends(current_user), ): error = await task_state_manager.get_error.remote(task_id) if error is None: @@ -490,7 +493,10 @@ async def get_task_error( status_code=status.HTTP_404_NOT_FOUND, detail=f"No error found for task '{task_id}'.", ) - return {"task_id": task_id, "traceback": error.splitlines()} + # Only admins get the raw traceback (it exposes paths/internals). + if user and user.get("is_admin", False): + return {"task_id": task_id, "traceback": error.splitlines()} + return {"task_id": task_id, "traceback": ["Task failed. Contact an administrator for details."]} @router.get( diff --git a/openrag/routers/openai.py b/openrag/routers/openai.py index 30faed751..2f7d13cb9 100644 --- a/openrag/routers/openai.py +++ b/openrag/routers/openai.py @@ -323,8 +323,9 @@ async def openai_chat_completion( truncate(str(request.messages)), ) + # Bound the caller's input size in both RAG and direct-LLM modes. + check_tokens_limit(request, log) if is_direct_llm_model(request): - check_tokens_limit(request, log) partitions = None else: partitions = await get_partition_name(model_name, user_partitions, is_admin=user["is_admin"]) @@ -418,8 +419,8 @@ async def openai_completion( detail="Streaming is not supported for this endpoint", ) + check_tokens_limit(request, log) if is_direct_llm_model(request): - check_tokens_limit(request, log) partitions = None else: partitions = await get_partition_name(model_name, user_partitions, is_admin=user["is_admin"]) diff --git a/openrag/routers/partition.py b/openrag/routers/partition.py index 9942b300e..c8a2027fc 100644 --- a/openrag/routers/partition.py +++ b/openrag/routers/partition.py @@ -1,3 +1,4 @@ +import os from typing import Literal from urllib.parse import quote @@ -228,13 +229,25 @@ async def list_all_chunks( ) async def create_partition(request: Request, partition: str, vectordb=Depends(get_vectordb)): assert_valid_partition_name(partition) - if await vectordb.partition_exists.remote(partition): + user = request.state.user + user_id = user["id"] + + # Cap partitions per non-admin user. The count+create is done atomically in + # the Vectordb actor (serialized), so concurrent requests can't race past it. + # max_owned=None (admin) or < 0 disables the cap. + max_owned = None if user.get("is_admin", False) else int(os.environ.get("MAX_PARTITIONS_PER_USER", "100")) + + result = await vectordb.create_partition.remote(partition=partition, user_id=user_id, max_owned=max_owned) + if result == "exists": raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"Partition '{partition}' already exists.", ) - user_id = request.state.user["id"] - await vectordb.create_partition.remote(partition=partition, user_id=user_id) + if result == "limit_exceeded": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Partition limit reached ({max_owned}). Contact an administrator.", + ) return Response(status_code=status.HTTP_201_CREATED) diff --git a/openrag/routers/test_auth_router.py b/openrag/routers/test_auth_router.py index 83a5eb57d..f0ec25399 100644 --- a/openrag/routers/test_auth_router.py +++ b/openrag/routers/test_auth_router.py @@ -104,6 +104,7 @@ def _logout_token_payload(*, sid: str | None = None, sub: str | None = None) -> "iss": ISSUER, "aud": CLIENT_ID, "iat": now, + "exp": now + 300, "jti": "lt-001", "events": {"http://schemas.openid.net/event/backchannel-logout": {}}, } diff --git a/openrag/utils/ssrf.py b/openrag/utils/ssrf.py new file mode 100644 index 000000000..c4d35f80c --- /dev/null +++ b/openrag/utils/ssrf.py @@ -0,0 +1,53 @@ +"""SSRF guards for outbound fetches of URLs from documents or users. + +is_blocked_url_literal is a no-DNS pre-check (scheme + literal IP). guard_request +is an httpx hook that resolves the host and rejects non-global addresses, on the +first request and on each redirect. +""" + +import asyncio +import ipaddress +import socket +from urllib.parse import urlparse + +import httpx +from utils.logger import get_logger + +logger = get_logger() + + +def is_blocked_url_literal(url: str) -> bool: + """Reject non-HTTP(S) schemes and non-global literal IPs (no DNS lookup). + + Hostnames pass here and are checked against their resolved IPs in guard_request. + """ + parsed = urlparse(url) + if parsed.scheme not in ("http", "https"): + return True + host = parsed.hostname or "" + if not host or host == "localhost": + return True + try: + return not ipaddress.ip_address(host).is_global + except ValueError: + return False # Regular hostname → checked after DNS resolution + + +async def guard_request(request: httpx.Request) -> None: + """httpx hook: block requests whose host resolves to a non-global IP.""" + host = request.url.host + if not host: + raise httpx.RequestError("Blocked request with no host", request=request) + try: + infos = await asyncio.to_thread(socket.getaddrinfo, host, None) + except socket.gaierror as e: + raise httpx.RequestError(f"DNS resolution failed for {host}", request=request) from e + for info in infos: + ip = info[4][0] + try: + addr = ipaddress.ip_address(ip) + except ValueError: + raise httpx.RequestError(f"Unparseable address for {host}", request=request) + if not addr.is_global: + logger.warning("Blocked SSRF attempt to non-global address", host=host, ip=ip) + raise httpx.RequestError(f"Blocked non-global address {ip} for {host}", request=request) diff --git a/openrag_metrics/docker-compose.yaml b/openrag_metrics/docker-compose.yaml index d415825c9..1e85a6b3b 100644 --- a/openrag_metrics/docker-compose.yaml +++ b/openrag_metrics/docker-compose.yaml @@ -1,9 +1,10 @@ services: prometheus: - image: prom/prometheus:latest + image: prom/prometheus:v2.54.1 container_name: openrag-prometheus ports: - - "9090:9090" + # Localhost only: the Prometheus API is unauthenticated. + - "127.0.0.1:9090:9090" volumes: - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro - ./prometheus/openrag_token:/etc/prometheus/openrag_token:ro @@ -11,13 +12,14 @@ services: command: - "--config.file=/etc/prometheus/prometheus.yml" - "--storage.tsdb.retention.time=30d" - - "--web.enable-lifecycle" + # --web.enable-lifecycle is intentionally NOT set: it exposes unauthenticated + # /-/reload and /-/quit endpoints. Re-enable only behind an auth proxy. extra_hosts: - "host.docker.internal:host-gateway" restart: unless-stopped grafana: - image: grafana/grafana:latest + image: grafana/grafana:11.2.2 container_name: openrag-grafana ports: - "3000:3000" @@ -34,10 +36,12 @@ services: restart: unless-stopped node-exporter: - image: prom/node-exporter:latest + image: prom/node-exporter:v1.8.2 container_name: openrag-node-exporter ports: - - "9100:9100" + # Localhost only: Prometheus scrapes it over the compose network by name, + # so it needn't be exposed on all interfaces (it surfaces host metrics). + - "127.0.0.1:9100:9100" pid: host volumes: - /proc:/host/proc:ro @@ -54,7 +58,8 @@ services: image: utkuozdemir/nvidia_gpu_exporter:1.2.0 container_name: openrag-nvidia-gpu-exporter ports: - - "9835:9835" + # Localhost only: scraped over the compose network by name. + - "127.0.0.1:9835:9835" volumes: - /usr/lib/x86_64-linux-gnu/libnvidia-ml.so:/usr/lib/x86_64-linux-gnu/libnvidia-ml.so:ro - /usr/lib/x86_64-linux-gnu/libnvidia-ml.so.1:/usr/lib/x86_64-linux-gnu/libnvidia-ml.so.1:ro diff --git a/pyproject.toml b/pyproject.toml index 2a9ba5f2d..cfb0a1a66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,9 @@ dependencies = [ "authlib>=1.3", "itsdangerous>=2.2", "cryptography>=42", + "limits>=3.6", + "starlette>=0.47.2,<0.48", + "fastapi>=0.116.1,<0.117", ] [dependency-groups] diff --git a/quick_start/docker-compose.yaml b/quick_start/docker-compose.yaml index 9cecd3a98..d62c1119d 100644 --- a/quick_start/docker-compose.yaml +++ b/quick_start/docker-compose.yaml @@ -4,7 +4,7 @@ include: x-openrag: &openrag_template # image: ghcr.io/linagora/openrag:dev-latest - image: linagoraai/openrag:latest + image: linagoraai/openrag:1.1.11 build: context: . dockerfile: Dockerfile @@ -16,7 +16,7 @@ x-openrag: &openrag_template # - ./logs:/app/logs ports: - ${APP_PORT:-8080}:${APP_iPORT:-8080} - - ${RAY_DASHBOARD_PORT:-8265}:8265 # Disable when in cluster mode + - 127.0.0.1:${RAY_DASHBOARD_PORT:-8265}:8265 # Localhost only: Ray dashboard/Jobs API is unauthenticated. Disable when in cluster mode networks: default: aliases: @@ -56,7 +56,7 @@ x-vllm: &vllm_template services: # OpenRAG Indexer UI indexer-ui: - image: linagoraai/indexer-ui:latest + image: linagoraai/indexer-ui:1.1.11 # build: # context: ./extern/indexer-ui # dockerfile: Dockerfile @@ -105,7 +105,7 @@ services: rdb: image: postgres:15 environment: - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-root_password} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in your .env} - POSTGRES_USER=${POSTGRES_USER:-root} volumes: - ${DB_VOLUME:-./db}:/var/lib/postgresql/data diff --git a/quick_start/vdb/milvus.yaml b/quick_start/vdb/milvus.yaml index 6a9f37e0c..a26ada08c 100644 --- a/quick_start/vdb/milvus.yaml +++ b/quick_start/vdb/milvus.yaml @@ -18,8 +18,8 @@ services: minio: image: minio/minio:RELEASE.2023-03-20T20-16-18Z environment: - MINIO_ACCESS_KEY: minioadmin - MINIO_SECRET_KEY: minioadmin + MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:?Set MINIO_ACCESS_KEY in your .env} + MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:?Set MINIO_SECRET_KEY in your .env} volumes: - ${MILVUS_VOLUME_DIRECTORY:-./volumes}/minio:/minio_data command: minio server /minio_data --console-address ":9001" @@ -32,11 +32,13 @@ services: milvus: image: milvusdb/milvus:v2.5.4 command: ["milvus", "run", "standalone"] - security_opt: - - seccomp:unconfined + # Run under Docker's default seccomp profile (do not disable syscall filtering). environment: ETCD_ENDPOINTS: etcd:2379 MINIO_ADDRESS: minio:9000 + # Keep Milvus's MinIO credentials in sync with the minio service above. + MINIO_ACCESS_KEY_ID: ${MINIO_ACCESS_KEY:?Set MINIO_ACCESS_KEY in your .env} + MINIO_SECRET_ACCESS_KEY: ${MINIO_SECRET_KEY:?Set MINIO_SECRET_KEY in your .env} volumes: - ${MILVUS_VOLUME_DIRECTORY:-./volumes}/milvus:/var/lib/milvus healthcheck: diff --git a/tests/api_tests/api_run/docker-compose.yaml b/tests/api_tests/api_run/docker-compose.yaml index f3085533a..be4c6dc2f 100644 --- a/tests/api_tests/api_run/docker-compose.yaml +++ b/tests/api_tests/api_run/docker-compose.yaml @@ -104,6 +104,9 @@ services: - DEFAULT_FILE_QUOTA=10 - AUTH_TOKEN=${AUTH_TOKEN:-test-admin-token} - SUPER_ADMIN_MODE=${SUPER_ADMIN_MODE:-true} + # The API test suite issues bursts of requests; disable rate limiting so + # it doesn't trip the limiter (the limiter is unit-tested separately). + - RATE_LIMIT_ENABLED=false ports: - "8080:8080" volumes: diff --git a/uv.lock b/uv.lock index 03bc35121..841cb7fcc 100644 --- a/uv.lock +++ b/uv.lock @@ -225,6 +225,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, ] +[[package]] +name = "audioop-lts" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/53/946db57842a50b2da2e0c1e34bd37f36f5aadba1a929a3971c5d7841dbca/audioop_lts-0.2.2.tar.gz", hash = "sha256:64d0c62d88e67b98a1a5e71987b7aa7b5bcffc7dcee65b635823dbdd0a8dbbd0", size = 30686, upload-time = "2025-08-05T16:43:17.409Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/d4/94d277ca941de5a507b07f0b592f199c22454eeaec8f008a286b3fbbacd6/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd3d4602dc64914d462924a08c1a9816435a2155d74f325853c1f1ac3b2d9800", size = 46523, upload-time = "2025-08-05T16:42:20.836Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5a/656d1c2da4b555920ce4177167bfeb8623d98765594af59702c8873f60ec/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:550c114a8df0aafe9a05442a1162dfc8fec37e9af1d625ae6060fed6e756f303", size = 27455, upload-time = "2025-08-05T16:42:22.283Z" }, + { url = "https://files.pythonhosted.org/packages/1b/83/ea581e364ce7b0d41456fb79d6ee0ad482beda61faf0cab20cbd4c63a541/audioop_lts-0.2.2-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:9a13dc409f2564de15dd68be65b462ba0dde01b19663720c68c1140c782d1d75", size = 26997, upload-time = "2025-08-05T16:42:23.849Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3b/e8964210b5e216e5041593b7d33e97ee65967f17c282e8510d19c666dab4/audioop_lts-0.2.2-cp313-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:51c916108c56aa6e426ce611946f901badac950ee2ddaf302b7ed35d9958970d", size = 85844, upload-time = "2025-08-05T16:42:25.208Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2e/0a1c52faf10d51def20531a59ce4c706cb7952323b11709e10de324d6493/audioop_lts-0.2.2-cp313-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47eba38322370347b1c47024defbd36374a211e8dd5b0dcbce7b34fdb6f8847b", size = 85056, upload-time = "2025-08-05T16:42:26.559Z" }, + { url = "https://files.pythonhosted.org/packages/75/e8/cd95eef479656cb75ab05dfece8c1f8c395d17a7c651d88f8e6e291a63ab/audioop_lts-0.2.2-cp313-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba7c3a7e5f23e215cb271516197030c32aef2e754252c4c70a50aaff7031a2c8", size = 93892, upload-time = "2025-08-05T16:42:27.902Z" }, + { url = "https://files.pythonhosted.org/packages/5c/1e/a0c42570b74f83efa5cca34905b3eef03f7ab09fe5637015df538a7f3345/audioop_lts-0.2.2-cp313-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def246fe9e180626731b26e89816e79aae2276f825420a07b4a647abaa84becc", size = 96660, upload-time = "2025-08-05T16:42:28.9Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/8a0ae607ca07dbb34027bac8db805498ee7bfecc05fd2c148cc1ed7646e7/audioop_lts-0.2.2-cp313-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e160bf9df356d841bb6c180eeeea1834085464626dc1b68fa4e1d59070affdc3", size = 79143, upload-time = "2025-08-05T16:42:29.929Z" }, + { url = "https://files.pythonhosted.org/packages/12/17/0d28c46179e7910bfb0bb62760ccb33edb5de973052cb2230b662c14ca2e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4b4cd51a57b698b2d06cb9993b7ac8dfe89a3b2878e96bc7948e9f19ff51dba6", size = 84313, upload-time = "2025-08-05T16:42:30.949Z" }, + { url = "https://files.pythonhosted.org/packages/84/ba/bd5d3806641564f2024e97ca98ea8f8811d4e01d9b9f9831474bc9e14f9e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4a53aa7c16a60a6857e6b0b165261436396ef7293f8b5c9c828a3a203147ed4a", size = 93044, upload-time = "2025-08-05T16:42:31.959Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5e/435ce8d5642f1f7679540d1e73c1c42d933331c0976eb397d1717d7f01a3/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:3fc38008969796f0f689f1453722a0f463da1b8a6fbee11987830bfbb664f623", size = 78766, upload-time = "2025-08-05T16:42:33.302Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/b909e76b606cbfd53875693ec8c156e93e15a1366a012f0b7e4fb52d3c34/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_s390x.whl", hash = "sha256:15ab25dd3e620790f40e9ead897f91e79c0d3ce65fe193c8ed6c26cffdd24be7", size = 87640, upload-time = "2025-08-05T16:42:34.854Z" }, + { url = "https://files.pythonhosted.org/packages/30/e7/8f1603b4572d79b775f2140d7952f200f5e6c62904585d08a01f0a70393a/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:03f061a1915538fd96272bac9551841859dbb2e3bf73ebe4a23ef043766f5449", size = 86052, upload-time = "2025-08-05T16:42:35.839Z" }, + { url = "https://files.pythonhosted.org/packages/b5/96/c37846df657ccdda62ba1ae2b6534fa90e2e1b1742ca8dcf8ebd38c53801/audioop_lts-0.2.2-cp313-abi3-win32.whl", hash = "sha256:3bcddaaf6cc5935a300a8387c99f7a7fbbe212a11568ec6cf6e4bc458c048636", size = 26185, upload-time = "2025-08-05T16:42:37.04Z" }, + { url = "https://files.pythonhosted.org/packages/34/a5/9d78fdb5b844a83da8a71226c7bdae7cc638861085fff7a1d707cb4823fa/audioop_lts-0.2.2-cp313-abi3-win_amd64.whl", hash = "sha256:a2c2a947fae7d1062ef08c4e369e0ba2086049a5e598fda41122535557012e9e", size = 30503, upload-time = "2025-08-05T16:42:38.427Z" }, + { url = "https://files.pythonhosted.org/packages/34/25/20d8fde083123e90c61b51afb547bb0ea7e77bab50d98c0ab243d02a0e43/audioop_lts-0.2.2-cp313-abi3-win_arm64.whl", hash = "sha256:5f93a5db13927a37d2d09637ccca4b2b6b48c19cd9eda7b17a2e9f77edee6a6f", size = 24173, upload-time = "2025-08-05T16:42:39.704Z" }, + { url = "https://files.pythonhosted.org/packages/58/a7/0a764f77b5c4ac58dc13c01a580f5d32ae8c74c92020b961556a43e26d02/audioop_lts-0.2.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:73f80bf4cd5d2ca7814da30a120de1f9408ee0619cc75da87d0641273d202a09", size = 47096, upload-time = "2025-08-05T16:42:40.684Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ed/ebebedde1a18848b085ad0fa54b66ceb95f1f94a3fc04f1cd1b5ccb0ed42/audioop_lts-0.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:106753a83a25ee4d6f473f2be6b0966fc1c9af7e0017192f5531a3e7463dce58", size = 27748, upload-time = "2025-08-05T16:42:41.992Z" }, + { url = "https://files.pythonhosted.org/packages/cb/6e/11ca8c21af79f15dbb1c7f8017952ee8c810c438ce4e2b25638dfef2b02c/audioop_lts-0.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fbdd522624141e40948ab3e8cdae6e04c748d78710e9f0f8d4dae2750831de19", size = 27329, upload-time = "2025-08-05T16:42:42.987Z" }, + { url = "https://files.pythonhosted.org/packages/84/52/0022f93d56d85eec5da6b9da6a958a1ef09e80c39f2cc0a590c6af81dcbb/audioop_lts-0.2.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:143fad0311e8209ece30a8dbddab3b65ab419cbe8c0dde6e8828da25999be911", size = 92407, upload-time = "2025-08-05T16:42:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/87/1d/48a889855e67be8718adbc7a01f3c01d5743c325453a5e81cf3717664aad/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfbbc74ec68a0fd08cfec1f4b5e8cca3d3cd7de5501b01c4b5d209995033cde9", size = 91811, upload-time = "2025-08-05T16:42:45.325Z" }, + { url = "https://files.pythonhosted.org/packages/98/a6/94b7213190e8077547ffae75e13ed05edc488653c85aa5c41472c297d295/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cfcac6aa6f42397471e4943e0feb2244549db5c5d01efcd02725b96af417f3fe", size = 100470, upload-time = "2025-08-05T16:42:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/e9/e9/78450d7cb921ede0cfc33426d3a8023a3bda755883c95c868ee36db8d48d/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:752d76472d9804ac60f0078c79cdae8b956f293177acd2316cd1e15149aee132", size = 103878, upload-time = "2025-08-05T16:42:47.576Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e2/cd5439aad4f3e34ae1ee852025dc6aa8f67a82b97641e390bf7bd9891d3e/audioop_lts-0.2.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:83c381767e2cc10e93e40281a04852facc4cd9334550e0f392f72d1c0a9c5753", size = 84867, upload-time = "2025-08-05T16:42:49.003Z" }, + { url = "https://files.pythonhosted.org/packages/68/4b/9d853e9076c43ebba0d411e8d2aa19061083349ac695a7d082540bad64d0/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c0022283e9556e0f3643b7c3c03f05063ca72b3063291834cca43234f20c60bb", size = 90001, upload-time = "2025-08-05T16:42:50.038Z" }, + { url = "https://files.pythonhosted.org/packages/58/26/4bae7f9d2f116ed5593989d0e521d679b0d583973d203384679323d8fa85/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a2d4f1513d63c795e82948e1305f31a6d530626e5f9f2605408b300ae6095093", size = 99046, upload-time = "2025-08-05T16:42:51.111Z" }, + { url = "https://files.pythonhosted.org/packages/b2/67/a9f4fb3e250dda9e9046f8866e9fa7d52664f8985e445c6b4ad6dfb55641/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:c9c8e68d8b4a56fda8c025e538e639f8c5953f5073886b596c93ec9b620055e7", size = 84788, upload-time = "2025-08-05T16:42:52.198Z" }, + { url = "https://files.pythonhosted.org/packages/70/f7/3de86562db0121956148bcb0fe5b506615e3bcf6e63c4357a612b910765a/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:96f19de485a2925314f5020e85911fb447ff5fbef56e8c7c6927851b95533a1c", size = 94472, upload-time = "2025-08-05T16:42:53.59Z" }, + { url = "https://files.pythonhosted.org/packages/f1/32/fd772bf9078ae1001207d2df1eef3da05bea611a87dd0e8217989b2848fa/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e541c3ef484852ef36545f66209444c48b28661e864ccadb29daddb6a4b8e5f5", size = 92279, upload-time = "2025-08-05T16:42:54.632Z" }, + { url = "https://files.pythonhosted.org/packages/4f/41/affea7181592ab0ab560044632571a38edaf9130b84928177823fbf3176a/audioop_lts-0.2.2-cp313-cp313t-win32.whl", hash = "sha256:d5e73fa573e273e4f2e5ff96f9043858a5e9311e94ffefd88a3186a910c70917", size = 26568, upload-time = "2025-08-05T16:42:55.627Z" }, + { url = "https://files.pythonhosted.org/packages/28/2b/0372842877016641db8fc54d5c88596b542eec2f8f6c20a36fb6612bf9ee/audioop_lts-0.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9191d68659eda01e448188f60364c7763a7ca6653ed3f87ebb165822153a8547", size = 30942, upload-time = "2025-08-05T16:42:56.674Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/baf2b9cc7e96c179bb4a54f30fcd83e6ecb340031bde68f486403f943768/audioop_lts-0.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c174e322bb5783c099aaf87faeb240c8d210686b04bd61dfd05a8e5a83d88969", size = 24603, upload-time = "2025-08-05T16:42:57.571Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/413b5a2804091e2c7d5def1d618e4837f1cb82464e230f827226278556b7/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:f9ee9b52f5f857fbaf9d605a360884f034c92c1c23021fb90b2e39b8e64bede6", size = 47104, upload-time = "2025-08-05T16:42:58.518Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/daa3308dc6593944410c2c68306a5e217f5c05b70a12e70228e7dd42dc5c/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:49ee1a41738a23e98d98b937a0638357a2477bc99e61b0f768a8f654f45d9b7a", size = 27754, upload-time = "2025-08-05T16:43:00.132Z" }, + { url = "https://files.pythonhosted.org/packages/4e/86/c2e0f627168fcf61781a8f72cab06b228fe1da4b9fa4ab39cfb791b5836b/audioop_lts-0.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b00be98ccd0fc123dcfad31d50030d25fcf31488cde9e61692029cd7394733b", size = 27332, upload-time = "2025-08-05T16:43:01.666Z" }, + { url = "https://files.pythonhosted.org/packages/c7/bd/35dce665255434f54e5307de39e31912a6f902d4572da7c37582809de14f/audioop_lts-0.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6d2e0f9f7a69403e388894d4ca5ada5c47230716a03f2847cfc7bd1ecb589d6", size = 92396, upload-time = "2025-08-05T16:43:02.991Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d2/deeb9f51def1437b3afa35aeb729d577c04bcd89394cb56f9239a9f50b6f/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9b0b8a03ef474f56d1a842af1a2e01398b8f7654009823c6d9e0ecff4d5cfbf", size = 91811, upload-time = "2025-08-05T16:43:04.096Z" }, + { url = "https://files.pythonhosted.org/packages/76/3b/09f8b35b227cee28cc8231e296a82759ed80c1a08e349811d69773c48426/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b267b70747d82125f1a021506565bdc5609a2b24bcb4773c16d79d2bb260bbd", size = 100483, upload-time = "2025-08-05T16:43:05.085Z" }, + { url = "https://files.pythonhosted.org/packages/0b/15/05b48a935cf3b130c248bfdbdea71ce6437f5394ee8533e0edd7cfd93d5e/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0337d658f9b81f4cd0fdb1f47635070cc084871a3d4646d9de74fdf4e7c3d24a", size = 103885, upload-time = "2025-08-05T16:43:06.197Z" }, + { url = "https://files.pythonhosted.org/packages/83/80/186b7fce6d35b68d3d739f228dc31d60b3412105854edb975aa155a58339/audioop_lts-0.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:167d3b62586faef8b6b2275c3218796b12621a60e43f7e9d5845d627b9c9b80e", size = 84899, upload-time = "2025-08-05T16:43:07.291Z" }, + { url = "https://files.pythonhosted.org/packages/49/89/c78cc5ac6cb5828f17514fb12966e299c850bc885e80f8ad94e38d450886/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0d9385e96f9f6da847f4d571ce3cb15b5091140edf3db97276872647ce37efd7", size = 89998, upload-time = "2025-08-05T16:43:08.335Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4b/6401888d0c010e586c2ca50fce4c903d70a6bb55928b16cfbdfd957a13da/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:48159d96962674eccdca9a3df280e864e8ac75e40a577cc97c5c42667ffabfc5", size = 99046, upload-time = "2025-08-05T16:43:09.367Z" }, + { url = "https://files.pythonhosted.org/packages/de/f8/c874ca9bb447dae0e2ef2e231f6c4c2b0c39e31ae684d2420b0f9e97ee68/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fefe5868cd082db1186f2837d64cfbfa78b548ea0d0543e9b28935ccce81ce9", size = 84843, upload-time = "2025-08-05T16:43:10.749Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/0323e66f3daebc13fd46b36b30c3be47e3fc4257eae44f1e77eb828c703f/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:58cf54380c3884fb49fdd37dfb7a772632b6701d28edd3e2904743c5e1773602", size = 94490, upload-time = "2025-08-05T16:43:12.131Z" }, + { url = "https://files.pythonhosted.org/packages/98/6b/acc7734ac02d95ab791c10c3f17ffa3584ccb9ac5c18fd771c638ed6d1f5/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:088327f00488cdeed296edd9215ca159f3a5a5034741465789cad403fcf4bec0", size = 92297, upload-time = "2025-08-05T16:43:13.139Z" }, + { url = "https://files.pythonhosted.org/packages/13/c3/c3dc3f564ce6877ecd2a05f8d751b9b27a8c320c2533a98b0c86349778d0/audioop_lts-0.2.2-cp314-cp314t-win32.whl", hash = "sha256:068aa17a38b4e0e7de771c62c60bbca2455924b67a8814f3b0dee92b5820c0b3", size = 27331, upload-time = "2025-08-05T16:43:14.19Z" }, + { url = "https://files.pythonhosted.org/packages/72/bb/b4608537e9ffcb86449091939d52d24a055216a36a8bf66b936af8c3e7ac/audioop_lts-0.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a5bf613e96f49712073de86f20dbdd4014ca18efd4d34ed18c75bd808337851b", size = 31697, upload-time = "2025-08-05T16:43:15.193Z" }, + { url = "https://files.pythonhosted.org/packages/f6/22/91616fe707a5c5510de2cac9b046a30defe7007ba8a0c04f9c08f27df312/audioop_lts-0.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:b492c3b040153e68b9fdaff5913305aaaba5bb433d8a7f73d5cf6a64ed3cc1dd", size = 25206, upload-time = "2025-08-05T16:43:16.444Z" }, +] + [[package]] name = "authlib" version = "1.7.0" @@ -468,11 +524,12 @@ wheels = [ [[package]] name = "chainlit" -version = "2.6.2" +version = "2.11.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiofiles" }, { name = "asyncer" }, + { name = "audioop-lts", marker = "python_full_version >= '3.13'" }, { name = "click" }, { name = "dataclasses-json" }, { name = "fastapi" }, @@ -484,6 +541,7 @@ dependencies = [ { name = "nest-asyncio" }, { name = "packaging" }, { name = "pydantic" }, + { name = "pydantic-settings" }, { name = "pyjwt" }, { name = "python-dotenv" }, { name = "python-multipart" }, @@ -494,9 +552,9 @@ dependencies = [ { name = "uvicorn" }, { name = "watchfiles" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/80/c519168d27b3f226b75348ba37c2b01e07ec2dd67b0d0dcb37634d5c777d/chainlit-2.6.2.tar.gz", hash = "sha256:4dd3568c83ee301ab7db0d08521b7cc22b739da76549d55cb271a66dd1233d93", size = 9523061, upload-time = "2025-07-16T04:29:41.541Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/87/080352b58401851f4bc9d595fc222103d431eb3e4bb2df2e9659028d3203/chainlit-2.11.1.tar.gz", hash = "sha256:74b7f801c63f39e7b4d99fd1fa9654df4a97ce01a07c739558566d750b5de3b6", size = 11205431, upload-time = "2026-04-22T00:56:31.464Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/5f/1c6e479dcb5af41a3e4094932a9b633610c81add27a4f47b783b9f00e5de/chainlit-2.6.2-py3-none-any.whl", hash = "sha256:f630c2e8dc45a8dc02539de192d27f6d51d9a7da2e6c029c875abc8eb1cc297f", size = 9674525, upload-time = "2025-07-16T04:29:39.138Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a2/3bbabfdb6bcbb9b0f952eac62ff277464d6359d126ce8f254ba513c1d4c8/chainlit-2.11.1-py3-none-any.whl", hash = "sha256:7cbd341a6cb2b7788845713dc41f86a44c6b36073dcd0fedb0fc7f230b542ec7", size = 11314614, upload-time = "2026-04-22T00:56:26.413Z" }, ] [[package]] @@ -959,16 +1017,16 @@ wheels = [ [[package]] name = "fastapi" -version = "0.115.14" +version = "0.116.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "starlette" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ca/53/8c38a874844a8b0fa10dd8adf3836ac154082cf88d3f22b544e9ceea0a15/fastapi-0.115.14.tar.gz", hash = "sha256:b1de15cdc1c499a4da47914db35d0e4ef8f1ce62b624e94e0e5824421df99739", size = 296263, upload-time = "2025-06-26T15:29:08.21Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/64/1296f46d6b9e3b23fb22e5d01af3f104ef411425531376212f1eefa2794d/fastapi-0.116.2.tar.gz", hash = "sha256:231a6af2fe21cfa2c32730170ad8514985fc250bec16c9b242d3b94c835ef529", size = 298595, upload-time = "2025-09-16T18:29:23.058Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/53/50/b1222562c6d270fea83e9c9075b8e8600b8479150a18e4516a6138b980d1/fastapi-0.115.14-py3-none-any.whl", hash = "sha256:6c0c8bf9420bd58f565e585036d971872472b4f7d3f6c73b698e10cffdefb3ca", size = 95514, upload-time = "2025-06-26T15:29:06.49Z" }, + { url = "https://files.pythonhosted.org/packages/32/e4/c543271a8018874b7f682bf6156863c416e1334b8ed3e51a69495c5d4360/fastapi-0.116.2-py3-none-any.whl", hash = "sha256:c3a7a8fb830b05f7e087d920e0d786ca1fc9892eb4e9a84b227be4c1bc7569db", size = 95670, upload-time = "2025-09-16T18:29:21.329Z" }, ] [[package]] @@ -1827,6 +1885,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/83/60/d497a310bde3f01cb805196ac61b7ad6dc5dcf8dce66634dc34364b20b4f/lazy_loader-0.4-py3-none-any.whl", hash = "sha256:342aa8e14d543a154047afb4ba8ef17f5563baad3fc610d7b15b213b0f119efc", size = 12097, upload-time = "2024-04-05T13:03:10.514Z" }, ] +[[package]] +name = "limits" +version = "5.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "packaging" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/69/826a5d1f45426c68d8f6539f8d275c0e4fcaa57f0c017ec3100986558a41/limits-5.8.0.tar.gz", hash = "sha256:c9e0d74aed837e8f6f50d1fcebcf5fd8130957287206bc3799adaee5092655da", size = 226104, upload-time = "2026-02-05T07:17:35.859Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/98/cb5ca20618d205a09d5bec7591fbc4130369c7e6308d9a676a28ff3ab22c/limits-5.8.0-py3-none-any.whl", hash = "sha256:ae1b008a43eb43073c3c579398bd4eb4c795de60952532dc24720ab45e1ac6b8", size = 60954, upload-time = "2026-02-05T07:17:34.425Z" }, +] + [[package]] name = "literalai" version = "0.1.201" @@ -2648,6 +2720,7 @@ dependencies = [ { name = "einops" }, { name = "eml-parser" }, { name = "fast-langdetect" }, + { name = "fastapi" }, { name = "faster-whisper" }, { name = "hdbscan" }, { name = "html-to-markdown" }, @@ -2659,6 +2732,7 @@ dependencies = [ { name = "langchain-huggingface" }, { name = "langchain-openai" }, { name = "langchain-qdrant" }, + { name = "limits" }, { name = "llvmlite" }, { name = "loguru" }, { name = "lxml" }, @@ -2683,6 +2757,7 @@ dependencies = [ { name = "ruff" }, { name = "spire-doc" }, { name = "sqlalchemy-utils" }, + { name = "starlette" }, { name = "torch" }, { name = "umap-learn" }, ] @@ -2712,6 +2787,7 @@ requires-dist = [ { name = "einops", specifier = ">=0.8.1" }, { name = "eml-parser", specifier = ">=2.0.0" }, { name = "fast-langdetect", specifier = ">=1.0.0" }, + { name = "fastapi", specifier = ">=0.116.1,<0.117" }, { name = "faster-whisper", specifier = ">=1.1.0" }, { name = "hdbscan", specifier = ">=0.8.40" }, { name = "html-to-markdown", specifier = ">=2.4.0" }, @@ -2723,6 +2799,7 @@ requires-dist = [ { name = "langchain-huggingface", specifier = ">=0.1.2" }, { name = "langchain-openai", specifier = ">=0.3.7" }, { name = "langchain-qdrant", specifier = ">=0.2.0" }, + { name = "limits", specifier = ">=3.6" }, { name = "llvmlite", specifier = ">=0.44.0" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "lxml", specifier = ">=5.0.0" }, @@ -2747,6 +2824,7 @@ requires-dist = [ { name = "ruff", specifier = ">=0.14.1" }, { name = "spire-doc", specifier = ">=13.1.0" }, { name = "sqlalchemy-utils" }, + { name = "starlette", specifier = ">=0.47.2,<0.48" }, { name = "torch", specifier = ">=2.4.1" }, { name = "umap-learn", specifier = ">=0.5.9.post2" }, ] @@ -4886,14 +4964,15 @@ wheels = [ [[package]] name = "starlette" -version = "0.46.2" +version = "0.47.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846, upload-time = "2025-04-13T13:56:17.942Z" } +sdist = { url = "https://files.pythonhosted.org/packages/15/b9/cc3017f9a9c9b6e27c5106cc10cc7904653c3eec0729793aec10479dd669/starlette-0.47.3.tar.gz", hash = "sha256:6bc94f839cc176c4858894f1f8908f0ab79dfec1a6b8402f6da9be26ebea52e9", size = 2584144, upload-time = "2025-08-24T13:36:42.122Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fd/901cfa59aaa5b30a99e16876f11abe38b59a1a2c51ffb3d7142bb6089069/starlette-0.47.3-py3-none-any.whl", hash = "sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51", size = 72991, upload-time = "2025-08-24T13:36:40.887Z" }, ] [[package]] @@ -5349,26 +5428,88 @@ wheels = [ [[package]] name = "watchfiles" -version = "0.20.0" +version = "1.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ef/48/02d2d2cbf54e134810b2cb40ac79fdb8ce08476184536a4764717a7bc9f4/watchfiles-0.20.0.tar.gz", hash = "sha256:728575b6b94c90dd531514677201e8851708e6e4b5fe7028ac506a200b622019", size = 37041, upload-time = "2023-08-24T12:49:17.616Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/db/899832e11fef2d468bf8b3c1c13289b1db4cb7c3410bb2a9612a52fc8b22/watchfiles-0.20.0-cp37-abi3-macosx_10_7_x86_64.whl", hash = "sha256:3796312bd3587e14926013612b23066912cf45a14af71cf2b20db1c12dadf4e9", size = 417357, upload-time = "2023-08-24T12:48:43.687Z" }, - { url = "https://files.pythonhosted.org/packages/9f/1a/85c914e4db62a3f8197daa98a271ea380a5d200a8d3058bd9f417752bc26/watchfiles-0.20.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:d0002d81c89a662b595645fb684a371b98ff90a9c7d8f8630c82f0fde8310458", size = 407258, upload-time = "2023-08-24T12:48:45.7Z" }, - { url = "https://files.pythonhosted.org/packages/25/ae/b7bddad421af5e33079a2ce639aa58837b715a2da98df16e25ecd310af52/watchfiles-0.20.0-cp37-abi3-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:570848706440373b4cd8017f3e850ae17f76dbdf1e9045fc79023b11e1afe490", size = 1331327, upload-time = "2023-08-24T12:48:47.005Z" }, - { url = "https://files.pythonhosted.org/packages/21/e5/b080cec4e841b1cf338ccbd958cf3232ad1691a590653b2d124b5c79cf6b/watchfiles-0.20.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9a0351d20d03c6f7ad6b2e8a226a5efafb924c7755ee1e34f04c77c3682417fa", size = 1301371, upload-time = "2023-08-24T12:48:48.338Z" }, - { url = "https://files.pythonhosted.org/packages/05/a0/2fb2c36730995a6b3f060187195dc08ad9ceee67426bdca8a4296024071c/watchfiles-0.20.0-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:007dcc4a401093010b389c044e81172c8a2520dba257c88f8828b3d460c6bb38", size = 1302438, upload-time = "2023-08-24T12:48:49.816Z" }, - { url = "https://files.pythonhosted.org/packages/13/ea/d11971958ae703cfe443b21f672169cb8bc12dbec5781b910633fa2186ec/watchfiles-0.20.0-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0d82dbc1832da83e441d112069833eedd4cf583d983fb8dd666fbefbea9d99c0", size = 1410655, upload-time = "2023-08-24T12:48:51.758Z" }, - { url = "https://files.pythonhosted.org/packages/6b/81/3f922f3ede53ca9c0b4095f63688ffeea19a49592d0ac62db1eb9632b1e3/watchfiles-0.20.0-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99f4c65fd2fce61a571b2a6fcf747d6868db0bef8a934e8ca235cc8533944d95", size = 1494222, upload-time = "2023-08-24T12:48:54.331Z" }, - { url = "https://files.pythonhosted.org/packages/e1/46/c9d5ee4871b187d291d62e61c41f9a4d67d4866a89704b0ad16b6949e9bd/watchfiles-0.20.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5392dd327a05f538c56edb1c6ebba6af91afc81b40822452342f6da54907bbdf", size = 1294171, upload-time = "2023-08-24T12:48:56.288Z" }, - { url = "https://files.pythonhosted.org/packages/59/5e/6b64e3bf9fd4422250f3c716d992dd76dbe55e6fa1e7ebaf2bf88f389707/watchfiles-0.20.0-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:08dc702529bb06a2b23859110c214db245455532da5eaea602921687cfcd23db", size = 1462256, upload-time = "2023-08-24T12:48:57.638Z" }, - { url = "https://files.pythonhosted.org/packages/11/c0/75f5a71ac24118ab11bd898e0114cedc72b25924ff2d960d473bddb4ec6e/watchfiles-0.20.0-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:7d4e66a857621584869cfbad87039e65dadd7119f0d9bb9dbc957e089e32c164", size = 1461725, upload-time = "2023-08-24T12:48:59.713Z" }, - { url = "https://files.pythonhosted.org/packages/91/d4/0c0fdcc4293ad1b73db54896fa0de4b37439ae4f25971b5eb1708dd04f9a/watchfiles-0.20.0-cp37-abi3-win32.whl", hash = "sha256:a03d1e6feb7966b417f43c3e3783188167fd69c2063e86bad31e62c4ea794cc5", size = 268193, upload-time = "2023-08-24T12:49:01.101Z" }, - { url = "https://files.pythonhosted.org/packages/87/79/098b1b1fcb6de16149d23283a2ab5dadce6a06b864e7a182d231f57a1f9e/watchfiles-0.20.0-cp37-abi3-win_amd64.whl", hash = "sha256:eccc8942bcdc7d638a01435d915b913255bbd66f018f1af051cd8afddb339ea3", size = 276723, upload-time = "2023-08-24T12:49:02.351Z" }, - { url = "https://files.pythonhosted.org/packages/3f/82/45dddf4f5bf8b73ba27382cebb2bb3c0ee922c7ef77d936b86276aa39dca/watchfiles-0.20.0-cp37-abi3-win_arm64.whl", hash = "sha256:b17d4176c49d207865630da5b59a91779468dd3e08692fe943064da260de2c7c", size = 265344, upload-time = "2023-08-24T12:49:04.107Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, ] [[package]] diff --git a/vdb/milvus.yaml b/vdb/milvus.yaml index 8e34d7e93..ff0bc76cd 100644 --- a/vdb/milvus.yaml +++ b/vdb/milvus.yaml @@ -18,8 +18,8 @@ services: minio: image: minio/minio:RELEASE.2024-12-18T13-15-44Z environment: - MINIO_ACCESS_KEY: minioadmin - MINIO_SECRET_KEY: minioadmin + MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:?Set MINIO_ACCESS_KEY in your .env} + MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:?Set MINIO_SECRET_KEY in your .env} volumes: - ${MILVUS_VOLUME_DIRECTORY:-./volumes}/minio:/minio_data command: minio server /minio_data --console-address ":9001" @@ -32,11 +32,16 @@ services: milvus: image: milvusdb/milvus:v2.6.11 command: ["milvus", "run", "standalone"] - security_opt: - - seccomp:unconfined + # Run under Docker's default seccomp profile (do not disable syscall + # filtering). If a specific kernel needs a wider profile, supply a vetted + # custom profile rather than seccomp:unconfined. environment: ETCD_ENDPOINTS: etcd:2379 MINIO_ADDRESS: minio:9000 + # Milvus must authenticate to MinIO with the same credentials; otherwise + # it falls back to the built-in minioadmin default and fails to connect. + MINIO_ACCESS_KEY_ID: ${MINIO_ACCESS_KEY:?Set MINIO_ACCESS_KEY in your .env} + MINIO_SECRET_ACCESS_KEY: ${MINIO_SECRET_KEY:?Set MINIO_SECRET_KEY in your .env} volumes: - ${MILVUS_VOLUME_DIRECTORY:-./volumes}/milvus:/var/lib/milvus healthcheck: