Files
DocsGPT/application/app.py
T
Alex 2565906f88 chore(deps): bump 47 backend dependencies
Bumps the Python backend dependency set, holding back the ones that are
resolver-blocked or that regress behaviour this repo depends on.

Notable upgrades:
  cryptography 46.0.7 -> 50.0.0 (requires msal 1.37.0, which relaxes its cap)
  protobuf 6.33.6 -> 7.35.1 (floats opentelemetry-* to 1.44.0)
  openai 2.32.0 -> 2.53.0, anthropic 0.88.0 -> 0.121.0
  google-genai 1.73.1 -> 2.17.0 (the 2.0 break is scoped to the Interactions
    API, which this repo does not use)
  fastmcp 3.2.4 -> 3.4.6, gunicorn 25.3.0 -> 26.0.0
  starlette 1.0.0 -> 1.6.0, uvicorn 0.42.0 -> 0.52.1
  sentence-transformers 5.3.0 -> 5.7.0, numpy 2.4.4 -> 2.5.1
  faiss-cpu 1.13.2 -> 1.15.0, pillow -> 12.3.0 (pinned; security release)
  pypdf 6.9.2 -> 6.15.0, lxml 6.0.2 -> 6.1.1 (CVE-2026-41066)

Code changes needed by the bumps:
  - openai >= 2.53 rejects a falsy api_key at construction. Keyless
    OpenAI-compatible backends (Ollama, llama.cpp, vLLM) legitimately have
    none, and pydantic-settings yields "" for a bare `API_KEY=` in .env, so
    both call sites now fall back to a placeholder.
  - Flask >= 3.1.2 tears a stream_with_context request down twice, and a
    ContextVar token may only be reset once. The log-context teardown hook
    is now idempotent.
  - sentence-transformers renamed get_sentence_embedding_dimension to
    get_embedding_dimension in 5.4; use the new name with a fallback.

Held back deliberately:
  torch 2.11.0      - 2.13.0 drags torchvision 0.26 -> 0.28 and the whole
                      docling stack; torch is only probed for cuda.empty_cache()
  tokenizers 0.22.2 - transformers pins <=0.23.0 and no stable 0.23.0 exists
  transformers      - capped <5.9.0: 5.9+ breaks docling's PDF layout model on
                      Apple Silicon (MPS float64), matching docling-core's own
                      darwin pin
  docling 2.84.0    - 2.118.1 needs rapidocr >=3.9.1 and conflicts with
                      transformers on macOS; wants its own PR with a
                      golden-corpus diff
  redis 7.4.0       - 8.x defaults socket_timeout to 5s, silently capping the
                      blocking reads in the device broker and SSE tail
  websockets 16.0   - google-genai caps <17.0
  marshmallow       - dataclasses-json hard-caps <4; spec tightened to match
  langchain block   - langchain-community 0.4.2 deletes the Qdrant vectorstore
                      this repo imports; langchain 1.3.x needs websockets <16
2026-08-09 12:30:44 +01:00

297 lines
11 KiB
Python

import logging
import os
import platform
import uuid
import dotenv
from flask import Flask, Response, jsonify, redirect, request
from jose import jwt
from application.auth import handle_auth
from application.core import log_context
from application.core.logging_config import setup_logging
setup_logging()
from application.api import api # noqa: E402
from application.api.admin import admin_ns # noqa: E402
from application.api.answer import answer # noqa: E402
from application.api.devices import devices_bp # noqa: E402
from application.api.events.routes import events # noqa: E402
from application.api.internal.routes import internal # noqa: E402
from application.api.oidc import oidc_bp # noqa: E402
from application.api.oidc.denylist import is_denied as oidc_session_denied # noqa: E402
from application.api.scim import scim_bp # noqa: E402
from application.api.user.authz import resolve_roles # noqa: E402
from application.api.user.routes import user # noqa: E402
from application.api.connector.routes import connector # noqa: E402
from application.api.v1 import v1_bp # noqa: E402
from application.celery_init import celery # noqa: E402
from application.core.settings import settings # noqa: E402
from application.storage.db.bootstrap import ensure_database_ready # noqa: E402
from application.stt.upload_limits import ( # noqa: E402
build_stt_file_size_limit_message,
should_reject_stt_request,
)
if platform.system() == "Windows":
import pathlib
pathlib.PosixPath = pathlib.WindowsPath
dotenv.load_dotenv()
# Self-bootstrap the user-data Postgres DB. Runs before any blueprint or
# repository touches the engine, so the first request can't race the
# schema being created. Gated by AUTO_CREATE_DB / AUTO_MIGRATE settings
# (default ON for dev; disable in prod if schema is managed out-of-band).
ensure_database_ready(
settings.POSTGRES_URI,
create_db=settings.AUTO_CREATE_DB,
migrate=settings.AUTO_MIGRATE,
logger=logging.getLogger("application.app"),
)
from application.agents.default_tools import ( # noqa: E402
validate_default_chat_tools,
)
validate_default_chat_tools()
app = Flask(__name__)
app.register_blueprint(user)
app.register_blueprint(answer)
app.register_blueprint(events)
app.register_blueprint(internal)
app.register_blueprint(connector)
app.register_blueprint(devices_bp)
app.register_blueprint(oidc_bp)
app.register_blueprint(scim_bp)
app.register_blueprint(v1_bp)
# Register the admin namespace once. The membership guard makes this idempotent
# if application.app is re-imported (coverage tests reload the module): without
# it, re-running add_namespace would re-register routes on the already-served
# first app and raise "add_url_rule can no longer be called".
if admin_ns not in api.namespaces:
api.add_namespace(admin_ns)
app.config.update(
UPLOAD_FOLDER="inputs",
CELERY_BROKER_URL=settings.CELERY_BROKER_URL,
CELERY_RESULT_BACKEND=settings.CELERY_RESULT_BACKEND,
MONGO_URI=settings.MONGO_URI,
)
celery.config_from_object("application.celeryconfig")
api.init_app(app)
if settings.AUTH_TYPE in ("simple_jwt", "session_jwt", "oidc") and not settings.JWT_SECRET_KEY:
key_file = ".jwt_secret_key"
try:
with open(key_file, "r") as f:
settings.JWT_SECRET_KEY = f.read().strip()
except FileNotFoundError:
new_key = os.urandom(32).hex()
with open(key_file, "w") as f:
f.write(new_key)
settings.JWT_SECRET_KEY = new_key
except Exception as e:
raise RuntimeError(f"Failed to setup JWT_SECRET_KEY: {e}")
if settings.AUTH_TYPE == "oidc":
_missing_oidc = [
name
for name in ("OIDC_ISSUER", "OIDC_CLIENT_ID", "OIDC_FRONTEND_URL")
if not getattr(settings, name)
]
if _missing_oidc:
raise RuntimeError(f"AUTH_TYPE=oidc requires settings: {', '.join(_missing_oidc)}")
SIMPLE_JWT_TOKEN = None
if settings.AUTH_TYPE == "simple_jwt":
payload = {"sub": "local"}
SIMPLE_JWT_TOKEN = jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm="HS256")
print(f"Generated Simple JWT Token: {SIMPLE_JWT_TOKEN}")
@app.route("/")
def home():
if request.remote_addr in ("0.0.0.0", "127.0.0.1", "localhost", "172.18.0.1"):
return redirect("http://localhost:5173")
else:
return "Welcome to DocsGPT Backend!"
@app.route("/api/health")
def health():
return jsonify({"status": "ok"})
@app.route("/api/config")
def get_config():
from application.graphrag import graphrag_available
response = {
"auth_type": settings.AUTH_TYPE,
"requires_auth": settings.AUTH_TYPE in ["simple_jwt", "session_jwt", "oidc"],
"graphrag_available": graphrag_available(),
"hybrid_available": settings.VECTOR_STORE == "pgvector",
}
if settings.AUTH_TYPE == "oidc":
response["oidc"] = {
"login_path": "/api/auth/oidc/login",
"logout_path": "/api/auth/oidc/logout",
"provider_name": settings.OIDC_PROVIDER_NAME,
}
return jsonify(response)
@app.route("/api/generate_token")
def generate_token():
if settings.AUTH_TYPE == "session_jwt":
new_user_id = str(uuid.uuid4())
token = jwt.encode(
{"sub": new_user_id}, settings.JWT_SECRET_KEY, algorithm="HS256"
)
return jsonify({"token": token})
return jsonify({"error": "Token generation not allowed in current auth mode"}), 400
_LOG_CTX_TOKEN_ATTR = "_log_ctx_token"
@app.before_request
def _bind_log_context():
"""Bind activity_id + endpoint for the duration of this request.
Runs before ``authenticate_request``; ``user_id`` is overlaid in a
follow-up handler once the JWT has been decoded.
"""
if request.method == "OPTIONS":
return None
activity_id = str(uuid.uuid4())
request.activity_id = activity_id
token = log_context.bind(
activity_id=activity_id,
endpoint=request.endpoint,
)
setattr(request, _LOG_CTX_TOKEN_ATTR, token)
return None
@app.teardown_request
def _reset_log_context(_exc):
# SSE streams keep yielding after teardown fires, but a2wsgi runs each
# request inside ``copy_context().run(...)``, so this reset doesn't
# leak into the stream's view of the context.
token = getattr(request, _LOG_CTX_TOKEN_ATTR, None)
if token is not None:
# Flask >= 3.1.2 tears a stream_with_context request down twice: once
# when the view returns, once when the generator is finalized. Clear
# the token first — resetting one twice raises RuntimeError.
setattr(request, _LOG_CTX_TOKEN_ATTR, None)
log_context.reset(token)
@app.before_request
def enforce_stt_request_size_limits():
if request.method == "OPTIONS":
return None
if should_reject_stt_request(request.path, request.content_length):
return (
jsonify(
{
"success": False,
"message": build_stt_file_size_limit_message(),
}
),
413,
)
return None
@app.before_request
def authenticate_request():
if request.method == "OPTIONS":
return "", 200
# OpenAI-compatible routes authenticate via opaque agent API keys in the
# Authorization header, which the JWT decoder below would reject. Defer
# auth to the route handlers (see application/api/v1/routes.py).
if request.path.startswith("/v1/"):
request.decoded_token = None
return None
# Remote-device CLI endpoints carry opaque ``tok_…`` session tokens
# (not JWTs); ``verify_device_session`` runs inside the route handler.
# The redeem endpoint is tokenless — it authenticates via the one-time
# ``user_code`` inside ``redeem_pairing`` — so it's exempt too. Pairing
# create + status stay JWT-protected (UI calls).
if (
request.path.startswith("/api/devices/poll")
or request.path.startswith("/api/devices/sessions/")
or request.path == "/api/devices/me"
or request.path == "/api/devices/pairings/redeem"
):
request.decoded_token = None
return None
# OIDC login/callback/token endpoints must stay reachable even when the
# browser still carries a stale or expired Bearer token — otherwise the
# 401 below would lock the user out of the only path to a fresh session.
if request.path.startswith("/api/auth/oidc/"):
request.decoded_token = None
return None
# SCIM provisioning authenticates with its own bearer token (SCIM_TOKEN),
# validated inside the blueprint.
if request.path.startswith("/scim/"):
request.decoded_token = None
return None
decoded_token = handle_auth(request)
if not decoded_token:
request.decoded_token = None
elif "error" in decoded_token:
return jsonify(decoded_token), 401
elif settings.AUTH_TYPE == "oidc" and oidc_session_denied(decoded_token):
# Back-channel logout / SCIM deactivation revoked this session.
return (
jsonify(
{
"message": "Authentication error: session revoked",
"error": "token_revoked",
}
),
401,
)
else:
# Resolve roles once here, the single authenticated chokepoint. Roles
# are computed (never read from the JWT) and overwrite any inbound
# 'roles' claim. /v1, device, oidc, and scim paths set decoded_token
# above and never reach here, so they stay role-less by design.
decoded_token["roles"] = resolve_roles(decoded_token)
request.decoded_token = decoded_token
@app.before_request
def _bind_user_id_to_log_context():
# Registered after ``authenticate_request`` (Flask runs before_request
# handlers in registration order), so ``request.decoded_token`` is
# populated by the time we read it. ``teardown_request`` unwinds the
# whole request-level bind, so no separate reset token is needed here.
if request.method == "OPTIONS":
return None
decoded_token = getattr(request, "decoded_token", None)
user_id = decoded_token.get("sub") if isinstance(decoded_token, dict) else None
if user_id:
log_context.bind(user_id=user_id)
return None
@app.after_request
def after_request(response: Response) -> Response:
"""Add CORS headers for the pure Flask development entrypoint."""
response.headers["Access-Control-Allow-Origin"] = "*"
response.headers["Access-Control-Allow-Headers"] = (
"Content-Type, Authorization, Idempotency-Key"
)
response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS"
return response
if __name__ == "__main__":
app.run(debug=settings.FLASK_DEBUG_MODE, port=7091)