diff --git a/application/api/events/routes.py b/application/api/events/routes.py index 9080a58a..d1097157 100644 --- a/application/api/events/routes.py +++ b/application/api/events/routes.py @@ -103,33 +103,18 @@ def _allow_replay( ) -> bool: """Per-user sliding-window snapshot-replay budget. - Fails open on Redis errors or when the budget is disabled. Empty-backlog - no-cursor connects skip INCR so dev double-mounts don't trip 429. + Fails open on Redis errors or when the budget is disabled. No-cursor + connects never consume budget: fresh sessions start live and do no + snapshot work, so counting them only starved real reconnects (a burst + of fresh tabs could 429 a user's cursor-bearing reconnect). """ budget = int(settings.EVENTS_REPLAY_BUDGET_REQUESTS_PER_WINDOW) if budget <= 0: return True if redis_client is None: return True - - # Cheap pre-check: only INCR when we might actually replay. XLEN - # is one Redis op; the alternative (INCR every connect) is two - # ops AND wrongly counts no-op probes. The check is conservative: - # if ``last_event_id`` is set we always INCR, even if the cursor - # has already overtaken the latest entry — that case is rare and - # short-lived, and probing further would mean a redundant XRANGE. if last_event_id is None: - try: - if int(redis_client.xlen(stream_key(user_id))) == 0: - return True - except Exception: - # XLEN probe failed; fall through to the INCR path so a - # transient Redis hiccup can't bypass the budget. - logger.debug( - "XLEN probe failed for replay budget check user=%s; " - "proceeding to INCR", - user_id, - ) + return True window = max(1, int(settings.EVENTS_REPLAY_BUDGET_WINDOW_SECONDS)) key = replay_budget_key(user_id) @@ -154,7 +139,7 @@ def _normalize_last_event_id(raw: Optional[str]) -> Optional[str]: Returns the value unchanged when it parses as a Redis Streams id, otherwise ``None`` — callers treat ``None`` as "client has nothing" - and replay from the start of the retained window. Invalid ids would + and start the stream live (no snapshot). Invalid ids would otherwise pass straight to XRANGE and surface as a quiet replay failure plus broken truncation detection. """ @@ -166,6 +151,21 @@ def _normalize_last_event_id(raw: Optional[str]) -> Optional[str]: return raw +def _replay_floor_id() -> Optional[str]: + """Oldest stream id the snapshot replay may reach, or ``None``. + + Streams ids are millisecond timestamps, so an age ceiling maps + directly to an id floor. MAXLEN caps the stream by count, not time — + for a low-traffic user 1000 entries can span weeks, and shipping + that on reconnect helps no one. + """ + max_age_hours = int(settings.EVENTS_REPLAY_MAX_AGE_HOURS) + if max_age_hours <= 0: + return None + floor_ms = int(time.time() * 1000) - max_age_hours * 3600 * 1000 + return f"{floor_ms}-0" + + def _replay_backlog( redis_client, user_id: str, last_event_id: Optional[str], max_count: int ) -> Iterator[tuple[str, str]]: @@ -175,8 +175,16 @@ def _replay_backlog( Parse failures are skipped; the Streams id is injected into the envelope so replay matches live-tail shape. """ - # Exclusive start: '(' skips the already-delivered entry. - start = f"({last_event_id}" if last_event_id else "-" + floor = _replay_floor_id() + if last_event_id is None: + start = floor or "-" + elif floor and stream_id_compare(last_event_id, floor) < 0: + # Cursor is past the age ceiling: clamp to the floor (inclusive). + # The caller emits ``backlog.truncated`` for this case. + start = floor + else: + # Exclusive start: '(' skips the already-delivered entry. + start = f"({last_event_id}" try: entries = redis_client.xrange( stream_key(user_id), min=start, max="+", count=max_count @@ -385,15 +393,34 @@ def stream_events() -> Response: try: if redis_client is None: return + if last_event_id is None: + # Fresh session: start live. A no-cursor connect has + # no state to catch up on — replaying the whole + # retained window shipped weeks-old entries on every + # tab-open (MAXLEN caps by count, not age). Clients + # that had state present a cursor; the malformed- + # cursor case already got its truncation notice + # before the subscribe loop. + return oldest = _oldest_retained_id(redis_client, user_id) - if ( - last_event_id - and oldest - and stream_id_compare(last_event_id, oldest) < 0 + floor = _replay_floor_id() + # The snapshot can't reach past whichever is newer: + # the MAXLEN'd window edge or the age floor. + effective_oldest = oldest + if floor and ( + effective_oldest is None + or stream_id_compare(floor, effective_oldest) > 0 ): - # The Last-Event-ID has slid off the MAXLEN window. - # Tell the client so it can fetch full state. - replay_lines.append(_truncation_notice_line(oldest)) + effective_oldest = floor + if ( + effective_oldest + and stream_id_compare(last_event_id, effective_oldest) < 0 + ): + # The Last-Event-ID has slid off the replayable + # window. Tell the client so it can fetch full state. + replay_lines.append( + _truncation_notice_line(effective_oldest) + ) replay_cap = int(settings.EVENTS_REPLAY_MAX_PER_REQUEST) for entry_id, sse_line in _replay_backlog( redis_client, user_id, last_event_id, replay_cap diff --git a/application/asgi.py b/application/asgi.py index 1ffb1d6d..787f94ca 100644 --- a/application/asgi.py +++ b/application/asgi.py @@ -10,9 +10,10 @@ from starlette.routing import Mount from application.api.async_sse import async_sse_routes from application.app import app as flask_app +from application.core.settings import settings from application.mcp_server import mcp -_WSGI_THREADPOOL = 32 +_WSGI_THREADPOOL = int(settings.WSGI_THREADPOOL_WORKERS) mcp_app = mcp.http_app(path="/") diff --git a/application/cache.py b/application/cache.py index 4e2b6719..e5a82db0 100644 --- a/application/cache.py +++ b/application/cache.py @@ -1,6 +1,7 @@ import hashlib import json import logging +import socket as _socket import time from threading import Lock @@ -11,6 +12,12 @@ from application.utils import get_hash logger = logging.getLogger(__name__) +# Upper bound on any single blocking read by a pub/sub subscriber. Must stay +# comfortably above Topic.subscribe's poll_timeout (1 s) — get_message's idle +# wait polls with select() and never trips socket_timeout, but a half-open +# connection's pending read (e.g. the health-check PONG) does. +PUBSUB_SOCKET_TIMEOUT_SECONDS = 10 + def _cache_default(value): # Image attachments arrive inline as bytes (see GoogleLLM.prepare_messages_with_attachments); @@ -51,6 +58,64 @@ def get_redis_instance(): return _redis_instance +_pubsub_redis_instance = None +_pubsub_redis_creation_failed = False + + +def _tcp_keepalive_options(): + """Kernel keepalive knobs for long-lived, mostly-idle pub/sub sockets. + + Probing well inside NAT/IPVS idle-expiry windows (Docker Swarm's IPVS + expires idle flows after ~15 min) keeps the flow-table entry alive and + lets the kernel surface a dead peer instead of leaving the socket + half-open. The constants are Linux-specific, so build the dict from + whatever this platform exposes. + """ + options = {} + for name, value in (("TCP_KEEPIDLE", 300), ("TCP_KEEPINTVL", 60), ("TCP_KEEPCNT", 3)): + const = getattr(_socket, name, None) + if const is not None: + options[const] = value + return options + + +def get_pubsub_redis_instance(): + """Redis client dedicated to pub/sub subscribers. + + Separate from ``get_redis_instance`` because subscribers hold a socket + open for the life of an SSE connection. Without ``socket_timeout``, a + connection silently dropped by NAT/IPVS blocks ``pubsub.get_message`` + forever — including the ``health_check_interval`` PONG read — pinning + the subscriber's WSGI thread until the worker restarts. Bounding every + read lets a dead subscriber fail within seconds and release its thread. + + Returns: + A shared ``redis.Redis`` client, or ``None`` if Redis is + unavailable or ``CACHE_REDIS_URL`` is invalid. + """ + global _pubsub_redis_instance, _pubsub_redis_creation_failed + if _pubsub_redis_instance is None and not _pubsub_redis_creation_failed: + with _instance_lock: + if _pubsub_redis_instance is None and not _pubsub_redis_creation_failed: + try: + _pubsub_redis_instance = redis.Redis.from_url( + settings.CACHE_REDIS_URL, + socket_connect_timeout=2, + socket_timeout=PUBSUB_SOCKET_TIMEOUT_SECONDS, + socket_keepalive=True, + socket_keepalive_options=_tcp_keepalive_options(), + health_check_interval=10, + ) + except ValueError as e: + logger.error(f"Invalid Redis URL: {e}") + _pubsub_redis_creation_failed = True # Stop future attempts + _pubsub_redis_instance = None + except redis.ConnectionError as e: + logger.error(f"Redis connection error: {e}") + _pubsub_redis_instance = None # Keep trying for connection errors + return _pubsub_redis_instance + + def gen_cache_key(messages, model="docgpt", tools=None): if not all(isinstance(msg, dict) for msg in messages): raise ValueError("All messages must be dictionaries.") diff --git a/application/core/settings.py b/application/core/settings.py index 7ea4e4b6..37a01eec 100644 --- a/application/core/settings.py +++ b/application/core/settings.py @@ -275,6 +275,7 @@ class Settings(BaseSettings): # --graceful-timeout). Keep below the gunicorn --timeout (180) watchdog. # Used by gunicorn_worker.BoundedDrainUvicornWorker. GRACEFUL_SHUTDOWN_TIMEOUT_SECONDS: int = 30 + WSGI_THREADPOOL_WORKERS: int = 96 # SSE keepalive comment cadence. Must sit under Cloudflare's 100s idle # close and iOS Safari's ~60s — 15s gives generous headroom. SSE_KEEPALIVE_SECONDS: int = 15 @@ -291,6 +292,7 @@ class Settings(BaseSettings): # connection cap above and the windowed budget below, total # enumeration throughput is bounded. EVENTS_REPLAY_MAX_PER_REQUEST: int = 200 + EVENTS_REPLAY_MAX_AGE_HOURS: int = 48 # Sliding-window cap on snapshot replays per user. Once the budget # is exhausted the route returns HTTP 429 with the cursor pinned; # the client backs off and retries after the window rolls over. diff --git a/application/streaming/broadcast_channel.py b/application/streaming/broadcast_channel.py index 07861d5d..a7dcc022 100644 --- a/application/streaming/broadcast_channel.py +++ b/application/streaming/broadcast_channel.py @@ -19,7 +19,9 @@ from __future__ import annotations import logging from typing import Callable, Iterator, Optional -from application.cache import get_redis_instance +import redis as redis_lib + +from application.cache import get_pubsub_redis_instance, get_redis_instance logger = logging.getLogger(__name__) @@ -65,8 +67,13 @@ class Topic: If Redis is unavailable, returns immediately without yielding. Cleanly unsubscribes on ``GeneratorExit`` (client disconnect). + + Uses the pub/sub-dedicated client (bounded ``socket_timeout``): a + subscriber whose connection went half-open must fail within seconds + and release its WSGI thread, not block in ``get_message`` until the + worker restarts. """ - redis = get_redis_instance() + redis = get_pubsub_redis_instance() if redis is None: logger.debug("Redis unavailable; subscribe to %s yielded nothing", self.name) return @@ -86,6 +93,17 @@ class Topic: while True: try: msg = pubsub.get_message(timeout=poll_timeout) + except redis_lib.exceptions.TimeoutError: + # A bounded read timed out mid-message/health-check: the + # connection is half-open (NAT/IPVS dropped the flow). + # End the subscription so the SSE client reconnects on a + # fresh connection; expected during network churn, so no + # stack trace. + logger.info( + "pubsub read timed out for %s; closing subscriber", + self.name, + ) + return except Exception: logger.exception("pubsub.get_message failed for %s", self.name) return diff --git a/application/version.py b/application/version.py index ae51ac8e..96c9eb60 100644 --- a/application/version.py +++ b/application/version.py @@ -2,7 +2,7 @@ from __future__ import annotations -__version__ = "0.17.2" +__version__ = "0.18.0" def get_version() -> str: diff --git a/deployment/docker-compose-local.yaml b/deployment/docker-compose-local.yaml index b21637f1..89ee631f 100644 --- a/deployment/docker-compose-local.yaml +++ b/deployment/docker-compose-local.yaml @@ -7,6 +7,7 @@ services: - VITE_API_HOST=http://localhost:7091 - VITE_API_STREAMING=$VITE_API_STREAMING - VITE_EMBEDDINGS_NAME=$EMBEDDINGS_NAME + - DOCKER=true ports: - "5173:5173" diff --git a/docs/components/ToolCards.jsx b/docs/components/ToolCards.jsx index edf73ae6..be5c9e89 100644 --- a/docs/components/ToolCards.jsx +++ b/docs/components/ToolCards.jsx @@ -4,12 +4,18 @@ import Image from 'next/image'; const iconMap = { 'API Tool': '/toolIcons/tool_api_tool.svg', - 'Brave Search Tool': '/toolIcons/tool_brave.svg', - 'Cryptoprice Tool': '/toolIcons/tool_cryptoprice.svg', - 'Ntfy Tool': '/toolIcons/tool_ntfy.svg', - 'PostgreSQL Tool': '/toolIcons/tool_postgres.svg', - 'Read Webpage Tool': '/toolIcons/tool_read_webpage.svg', - 'Telegram Tool': '/toolIcons/tool_telegram.svg' + 'Brave Search': '/toolIcons/tool_brave.svg', + 'DuckDuckGo Search': '/toolIcons/tool_duckduckgo.svg', + 'CryptoPrice': '/toolIcons/tool_cryptoprice.svg', + 'Ntfy': '/toolIcons/tool_ntfy.svg', + 'Telegram Bot': '/toolIcons/tool_telegram.svg', + 'PostgreSQL Database': '/toolIcons/tool_postgres.svg', + 'Read Webpage (browser)': '/toolIcons/tool_read_webpage.svg', + 'Remote Device': '/toolIcons/tool_remote_device.svg', + 'MCP Tool': '/toolIcons/tool_mcp_tool.svg', + 'Memory': '/toolIcons/tool_memory.svg', + 'Notepad': '/toolIcons/tool_notes.svg', + 'Todo List': '/toolIcons/tool_todo_list.svg' }; @@ -19,7 +25,7 @@ export function ToolCards({ items }) {
{items.map(({ title, link, description }) => { const isExternal = link.startsWith('https://'); - const iconSrc = iconMap[title] || '/default-icon.png'; // Default icon if not found + const iconSrc = iconMap[title]; // No icon rendered when the tool has none return (
` | Download the agent as a `*.agent.yaml` file. | +| `POST` | `/api/import_agent/plan` | Dry-run an import: parse a YAML file and return a resolution plan without creating anything. | +| `POST` | `/api/import_agent` | Import a YAML file, creating the agent as a **draft**. | + +```bash +# Export +curl -L "https://your-docsgpt/api/export_agent?id=" \ + -H "Authorization: Bearer " -o my-agent.agent.yaml + +# Preview what an import would do +curl -X POST https://your-docsgpt/api/import_agent/plan \ + -H "Authorization: Bearer " \ + --data-binary @my-agent.agent.yaml + +# Import (creates a draft agent) +curl -X POST https://your-docsgpt/api/import_agent \ + -H "Authorization: Bearer " \ + --data-binary @my-agent.agent.yaml +``` + +Notes: + +- **Secrets are stripped on export** — API keys and tool credentials are never written into the YAML, so you re-enter them after import. +- The **plan** endpoint resolves references (sources, tools, prompts) and reports what will be created or matched, so you can review before committing. +- Imported tool URLs are validated against SSRF protections, the same as when creating tools normally. +- **Workflow agents cannot be exported yet** — exporting one returns `400`. + +## Searching Conversations + +Search across your conversations by name and message content: + +```text +GET /api/search_conversations?q=&limit=30 +``` + +| Parameter | Default | Description | +| --- | --- | --- | +| `q` | — (required) | Case-insensitive substring to search for. | +| `limit` | `30` | Max results (max `100`). | + +Each result includes a `match_field` (`name`, `prompt`, or `response`) and a `match_snippet` showing the matched text in context, in addition to the fields returned by `/api/get_conversations`. diff --git a/docs/content/Agents/notifications.mdx b/docs/content/Agents/notifications.mdx new file mode 100644 index 00000000..fd70cfc5 --- /dev/null +++ b/docs/content/Agents/notifications.mdx @@ -0,0 +1,91 @@ +--- +title: Realtime Events & Notifications (SSE) +description: Subscribe to DocsGPT's server-sent events channel for live notifications — ingestion progress, tool approvals, MCP OAuth completion — and reconnect to an in-flight chat answer. +--- + +import { Callout } from 'nextra/components' + +# Realtime Events & Notifications + +DocsGPT pushes realtime updates to the browser over **Server-Sent Events (SSE)**. This is what powers the upload toasts, tool-approval prompts, and other live notifications in the UI. There are two channels: + +- **User events** — `GET /api/events`: a per-user notification stream (ingestion progress, tool approvals, MCP OAuth completion, …). +- **Chat reconnect** — `GET /api/messages//events`: resume an answer stream that was interrupted mid-generation. + + +Both channels require Redis (already a DocsGPT dependency). The publisher can be turned off instance-wide with `ENABLE_SSE_PUSH=false`. + + +## User events channel + +Open an SSE connection to receive notifications for the authenticated user: + +```text +GET /api/events +Accept: text/event-stream +Authorization: Bearer +``` + +Each event is a JSON object with a `type` field, for example: + +```text +id: 1718900000000-0 +data: {"type":"source.ingest.queued","scope":{"id":""}, ...} +``` + +Common event types: + +| Type | Meaning | +| --- | --- | +| `source.ingest.queued` | A source ingestion task was enqueued (drives the upload toast). | +| `mcp.oauth.completed` | An MCP server's OAuth handshake finished. | +| tool-approval events | An agent is requesting approval to run a tool. | +| `backlog.truncated` | The client's cursor slid off the retained backlog window — clear your cursor and refetch state. | + +### Reconnecting and backlog replay + +Events are journaled per user in a Redis Stream so a client that reconnects can catch up on what it missed. Send the last id you processed and DocsGPT replays everything after it: + +```text +GET /api/events +Last-Event-ID: 1718900000000-0 +``` + +(You may also pass it as a `last_event_id` query parameter.) Each delivered event carries its own `id:`, so your cursor advances as you read. If you fall a long way behind, the snapshot is delivered across several reconnects rather than all at once. + +A few bounded behaviors to be aware of: + +- The backlog is capped at `EVENTS_STREAM_MAXLEN` entries (default 1000). If your `Last-Event-ID` is older than the oldest retained entry, you receive a `backlog.truncated` event — reset your cursor and refetch current state. +- Each snapshot is capped at `EVENTS_REPLAY_MAX_PER_REQUEST` entries per request (default 200); reconnect to continue. +- There is a per-user cap on simultaneous connections (`SSE_MAX_CONCURRENT_PER_USER`, default 8) and a windowed replay budget. Exceeding either returns **HTTP 429** — back off and retry. + +## Chat answer reconnect + +When an answer is streaming and the connection drops, resume it without losing the in-progress generation: + +```text +GET /api/messages//events +``` + +This replays the message's events past your last-seen sequence number and tails the rest live. It is backed by the Postgres `message_events` journal (retained for `MESSAGE_EVENTS_RETENTION_DAYS`, default 14). + + +The chat reconnect endpoint is a native-async route served by the ASGI entrypoint. Under a plain `flask run` dev server it returns `404`; run the backend via the ASGI app (`uvicorn application.asgi:asgi_app`) or the production gunicorn uvicorn worker to use it. See the [Development Environment](/Deploying/Development-Environment) guide. + + +## Settings + +| Setting | Default | Purpose | +| --- | --- | --- | +| `ENABLE_SSE_PUSH` | `true` | Master switch for the publisher and channel. | +| `EVENTS_STREAM_MAXLEN` | `1000` | Per-user backlog cap (approximate). | +| `SSE_KEEPALIVE_SECONDS` | `15` | Keepalive comment-frame cadence (keep below your proxy's idle timeout). | +| `SSE_MAX_CONCURRENT_PER_USER` | `8` | Max simultaneous SSE connections per user (`0` disables the cap). | +| `EVENTS_REPLAY_MAX_PER_REQUEST` | `200` | Max backlog entries per replay request. | +| `EVENTS_REPLAY_BUDGET_REQUESTS_PER_WINDOW` | `30` | Per-user replay requests per window (`0` disables). | +| `EVENTS_REPLAY_BUDGET_WINDOW_SECONDS` | `60` | Replay budget window length. | +| `MESSAGE_EVENTS_RETENTION_DAYS` | `14` | Retention for the chat-stream `message_events` journal. | + + +Operators debugging delivery issues ("the toast never appeared", "the answer didn't reconnect") can follow the SSE notifications runbook in the repository at `docs/runbooks/sse-notifications.md`. + diff --git a/docs/content/Agents/openai-compatible.mdx b/docs/content/Agents/openai-compatible.mdx index 8cfb18b4..f2bc171e 100644 --- a/docs/content/Agents/openai-compatible.mdx +++ b/docs/content/Agents/openai-compatible.mdx @@ -1,6 +1,6 @@ --- title: OpenAI-Compatible API -description: Connect any OpenAI-compatible client to DocsGPT Agents via /v1/chat/completions. +description: Connect any OpenAI-compatible client to DocsGPT Agents via /v1/chat/completions — streaming, structured output, multimodal, tool calling, reasoning, and idempotent retries. --- import { Callout, Tabs } from 'nextra/components'; @@ -58,7 +58,7 @@ Authenticate with `Authorization: Bearer `. ## Streaming -Set `"stream": true`. You'll receive SSE chunks with `choices[0].delta.content`. DocsGPT-specific events (sources, tool calls) arrive as extra frames with a `docsgpt` key — standard clients ignore them. +Set `"stream": true`. You'll receive SSE chunks with `choices[0].delta.content`. DocsGPT-specific events (sources, tool calls) arrive as extra frames that carry a top-level `docsgpt` key on an otherwise-empty chunk — standard clients ignore them. ```python stream = client.chat.completions.create( @@ -70,6 +70,135 @@ for chunk in stream: print(chunk.choices[0].delta.content or "", end="", flush=True) ``` +## Sampling Parameters + +Standard OpenAI sampling parameters are forwarded to the model. When omitted, the agent's configured defaults apply. Supported: `temperature`, `max_tokens` (or `max_completion_tokens`), `top_p`, `frequency_penalty`, `presence_penalty`, `stop`, `seed`. + +```json +{ + "model": "docsgpt-agent", + "messages": [{"role": "user", "content": "Write a haiku about search"}], + "temperature": 0.2, + "max_tokens": 256, + "seed": 42 +} +``` + +## Structured Output + +You can force the model to return JSON matching a schema, using either the OpenAI `response_format` field or the `response_schema` convenience field. + + + + ```json + { + "model": "docsgpt-agent", + "messages": [{"role": "user", "content": "Extract the order id and total"}], + "response_format": { + "type": "json_schema", + "json_schema": { + "name": "order", + "strict": true, + "schema": { + "type": "object", + "properties": { + "order_id": {"type": "string"}, + "total": {"type": "number"} + }, + "required": ["order_id", "total"] + } + } + } + } + ``` + + + ```json + { + "model": "docsgpt-agent", + "messages": [{"role": "user", "content": "Extract the order id and total"}], + "response_schema": { + "type": "object", + "properties": { + "order_id": {"type": "string"}, + "total": {"type": "number"} + }, + "required": ["order_id", "total"] + } + } + ``` + + + +- `response_format` follows OpenAI Structured Outputs. `strict` defaults to `true`; set `strict: false` to relax enforcement. +- `response_format: {"type": "json_object"}` requests JSON without a fixed schema (the model is steered by the prompt). +- `response_schema` is a DocsGPT convenience: pass a raw JSON Schema object (or a `{"schema": {...}}` wrapper) directly. + +## Multimodal Input (text + images) + +User messages may use OpenAI typed-content arrays with `image_url` parts. Images are forwarded to vision-capable models. + +```json +{ + "model": "docsgpt-agent", + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "What's in this screenshot?"}, + {"type": "image_url", "image_url": {"url": "https://example.com/shot.png"}} + ] + } + ] +} +``` + +## Tool Calling (client-side, stateless) + +You can register your own tools and execute them on the client. The flow is stateless — OpenAI clients that don't carry a `conversation_id` re-send the full message history each turn, and DocsGPT rebuilds the agent from it. + +1. Send a request with a `tools` array. +2. If the agent decides to call a tool, the response comes back with `finish_reason: "tool_calls"` and a `tool_calls` array (and `content: null`). +3. Execute the tool(s) on your side, then **re-POST the full message history** with the assistant's `tool_calls` message followed by `role: "tool"` result messages. +4. DocsGPT continues the run and returns the final answer. + +```json +{ + "model": "docsgpt-agent", + "messages": [ + {"role": "user", "content": "What's the weather in Paris?"}, + {"role": "assistant", "tool_calls": [ + {"id": "call_1", "type": "function", + "function": {"name": "get_weather", "arguments": "{\"city\":\"Paris\"}"}} + ]}, + {"role": "tool", "tool_call_id": "call_1", "content": "18°C, clear"} + ], + "tools": [ { "type": "function", "function": { "name": "get_weather", "...": "..." } } ] +} +``` + +## Reasoning + +For models that emit reasoning ("thinking") tokens, the response surfaces them in a non-standard `reasoning_content` field (a `reasoning_content` delta when streaming). Standard clients ignore it; clients that understand it can display the model's thinking separately from the answer. + +## Idempotent Retries + +Add an `Idempotency-Key` header so a retried request returns the *stored first response* instead of re-running the agent (which would duplicate the answer and double-bill tokens). + +```bash +curl -X POST http://localhost:7091/v1/chat/completions \ + -H "Authorization: Bearer your_agent_api_key" \ + -H "Idempotency-Key: 8f1c...unique-per-request" \ + -H "Content-Type: application/json" \ + -d '{"model":"docsgpt-agent","messages":[{"role":"user","content":"hi"}]}' +``` + +- **Opt-in** — no header means today's behavior (every request runs). +- **Non-streaming only** — streaming replay is not supported. +- A completed key **replays the cached body** (and status) for **24 hours**. +- A request with a key whose first attempt is **still in flight** returns **HTTP 409**. +- Keys are scoped per agent and capped at **256 characters** (oversized keys are rejected). + ## System Prompt Override System messages are **dropped by default** — the agent's configured prompt is used. To allow callers to override it, enable **Allow prompt override** in the agent's Advanced settings. @@ -80,13 +209,30 @@ When an override is active, the agent's prompt template is replaced wholesale ## Conversation Persistence -Conversations are **always persisted** server-side, and the response includes -`docsgpt.conversation_id`. They never appear in the agent owner's sidebar — -`/v1` traffic is stored hidden, so external clients can't clutter the owner's -conversation list. +Conversations are **always persisted** server-side, and the response includes `docsgpt.conversation_id`. They never appear in the agent owner's sidebar — `/v1` traffic is stored hidden, so external clients can't clutter the owner's conversation list. -The legacy `{ "docsgpt": { "save_conversation": true } }` opt-in from older -releases (when `/v1` was stateless by default) is deprecated and ignored. +Stateless tool continuations (no `conversation_id`, e.g. opencode) skip persistence by default to avoid writing orphan rows; set `docsgpt.persist` to override. The legacy `docsgpt.save_conversation` flag from older releases is deprecated and ignored. + +## DocsGPT Extension Fields + +DocsGPT adds an optional `docsgpt` object to both requests and responses for features outside the OpenAI schema. + +**Request** (`docsgpt.*`): + +| Field | Description | +| --- | --- | +| `attachments` | List of attachment IDs to include as context for this turn. | +| `persist` | Force-enable/disable conversation persistence (mainly for stateless tool continuations). | + +**Response** (`docsgpt.*`): + +| Field | Description | +| --- | --- | +| `conversation_id` | Server-side conversation ID for this exchange. | +| `sources` | RAG sources used to answer. | +| `tool_calls` | Completed tool-call results from the run. | + +When streaming, these arrive on otherwise-empty chunks that carry a top-level `docsgpt` key, so strict OpenAI clients still validate each frame. ## When to Use Native Endpoints Instead diff --git a/docs/content/Agents/webhooks.mdx b/docs/content/Agents/webhooks.mdx index 814690b5..3ba61382 100644 --- a/docs/content/Agents/webhooks.mdx +++ b/docs/content/Agents/webhooks.mdx @@ -88,6 +88,28 @@ Send an HTTP `POST` request to the agent's unique webhook URL with the required +### Triggering with GET (query parameters) + +The webhook listener also accepts `GET` requests, mapping the query string to the payload. This is handy for systems that can only issue a simple `GET` (for example a "ping this URL" integration): + +```bash +curl "http://localhost:7091/api/webhooks/agents/your_webhook_token?question=Your+message+to+agent" +``` + +As with `POST`, the response is a JSON object containing the `task_id`. + +### Preventing duplicate triggers (Idempotency-Key) + +To make a trigger safe to retry, send an optional `Idempotency-Key` header. If the same key is seen again within 24 hours, DocsGPT does not enqueue a second task — it returns the **original** `task_id` instead. This prevents a retried or double-fired webhook from running the agent twice. + +```bash +curl -X POST \ + http://localhost:7091/api/webhooks/agents/your_webhook_token \ + -H "Content-Type: application/json" \ + -H "Idempotency-Key: order-4567-created" \ + -d '{"question": "Your message to agent"}' +``` + ### Step 2: Poll for the Result Once you have the `task_id`, periodically send a `GET` request to the `/api/task_status` endpoint until the task `status` is `SUCCESS` or `FAILURE`. diff --git a/docs/content/Deploying/Access-Control.mdx b/docs/content/Deploying/Access-Control.mdx new file mode 100644 index 00000000..1c452004 --- /dev/null +++ b/docs/content/Deploying/Access-Control.mdx @@ -0,0 +1,149 @@ +--- +title: Access Control, Roles & Teams +description: How DocsGPT models permissions — global admin/user roles (RBAC), the admin dashboard, teams with per-team roles, and sharing agents, sources, prompts, and tools. +--- + +import { Callout } from 'nextra/components' + +# Access Control, Roles & Teams + +DocsGPT has two independent permission planes: + +- **Global RBAC** — every user holds the `admin` or `user` role for the whole instance. Admins can manage other users and see instance-wide usage and audit data. +- **Team roles** — within a team a user is a `team_admin` or `team_member`, and resources can be shared to teams or individual members. + +The two planes never mix: a global `admin` is a superuser over *all* teams, but a `team_admin` is **not** a global admin. + + +Roles are resolved **server-side on every request** and are never trusted from the JWT. The frontend route guards are cosmetic — the server-side decorators are the real boundary. Persisted roles apply under `AUTH_TYPE=oidc`; token-only modes (`simple_jwt`, `session_jwt`) can never hold the admin role. + + +## Global roles (RBAC) + +| Role | Capabilities | +| --- | --- | +| `user` | The default. Owns their own conversations, sources, agents, prompts, and tools. | +| `admin` | Everything a user can do, plus the [admin dashboard](#admin-dashboard): user management, force-logout, role management, and instance-wide usage/audit. | + +To see the roles the current request resolves to, call: + +```text +GET /api/user/me → { "user_id": "...", "roles": ["user"], "email": "...", "name": "...", "picture": "..." } +``` + +This is the canonical way to check a caller's effective roles (distinct from `/api/config`, which only reports the instance `auth_type`). + +## Granting admin + +There are four ways an account becomes an admin: + +1. **The `grant_admin` script** — run `python scripts/grant_admin.py ` on the server to grant (or `--revoke` / `--list`) the admin role directly. This is the canonical bootstrap for the first admin. +2. **OIDC group mapping** — `OIDC_ADMIN_GROUPS` auto-grants admin to members of the listed IdP groups. See [below](#admin-via-oidc-groups). +3. **Local no-auth mode** — `LOCAL_MODE_ADMIN=true` grants admin when `AUTH_TYPE=None` (self-host, no authentication). +4. **Grant by an existing admin** — through the admin API/dashboard. + +| Setting | Default | Description | +| --- | --- | --- | +| `OIDC_ADMIN_GROUPS` | — | Comma-separated IdP groups whose members are granted the global `admin` role. Re-checked at every login and silent renewal. Unset = no OIDC admin mapping. | +| `LOCAL_MODE_ADMIN` | `false` | Grants admin in no-auth mode only (`AUTH_TYPE=None`). | + + +**Never set `LOCAL_MODE_ADMIN=true` on a networked deployment.** It only makes sense for a single-user local install with `AUTH_TYPE=None`, where there is no identity to check. + + +### Bootstrapping the first admin + +Granting admin through the dashboard itself requires *already being* an admin, which creates a chicken-and-egg problem on a fresh deployment. Break it one of these ways: + +- **Run the script** on the server: `python scripts/grant_admin.py ` (the `user_id` is the OIDC `sub`). The grant is written to `user_roles` with `source='manual'` and takes effect on the user's next request. Use `--list` to see current admins and `--revoke` to remove a manual grant. +- Set `OIDC_ADMIN_GROUPS` to a group you belong to, and sign in — you become an admin automatically. +- For a no-auth local install, use `LOCAL_MODE_ADMIN=true`. + +### Admin via OIDC groups + +When `OIDC_ADMIN_GROUPS` is set, group membership is mapped to the admin role at every sign-in *and* every [silent renewal](/Deploying/OIDC-SSO#silent-session-renewal) — so removing a user from the admin group revokes their admin at the next renewal, just like the [sign-in allowlist](/Deploying/OIDC-SSO#restricting-sign-in-by-group). It is independent of `OIDC_ALLOWED_GROUPS` (which controls *whether* a user may sign in at all). Leaving `OIDC_ADMIN_GROUPS` unset never mass-revokes admin. + +```env +OIDC_ADMIN_GROUPS=platform-admins +# OIDC_GROUPS_CLAIM=groups # only if your IdP uses a different claim name +``` + +If your IdP only exposes groups via the userinfo endpoint, DocsGPT backfills them from there during reconciliation, the same as the allowlist. + +## Admin dashboard + +Admins get a dashboard backed by a REST surface under `/api/admin` (every endpoint requires the admin role). All mutating actions are written to the `auth_events` audit log with the acting admin recorded in the metadata. + +| Method | Path | Description | +| --- | --- | --- | +| `GET` | `/api/admin/overview` | Instance overview counts. | +| `GET` | `/api/admin/users` | List users (paginated; supports a `user_id` filter). | +| `GET` | `/api/admin/users/` | Drill into a single user. | +| `PATCH` | `/api/admin/users/` | Activate / deactivate a user (deactivation revokes their sessions). | +| `POST` | `/api/admin/users//role` | Grant the admin role (guards against removing the last admin). | +| `DELETE` | `/api/admin/users//role` | Revoke the admin role. | +| `POST` | `/api/admin/users//revoke-sessions` | Force-logout a user. | +| `GET` | `/api/admin/admins` | List current admins. | +| `GET` | `/api/admin/usage` | Token-usage series and top users. | +| `GET` | `/api/admin/audit` | Authentication/admin audit feed. | +| `GET` | `/api/admin/devices/audit` | Remote-device audit feed. | +| `GET` | `/api/admin/teams` | Instance-wide oversight of all teams. | + + +Deactivating a user via the dashboard works for any auth type, while OIDC deployments can also offboard through [SCIM](/Deploying/OIDC-SSO#scim-user-provisioning). Both revoke live sessions immediately. + + +## Teams + +Teams let a group of users collaborate and share resources. Teams are self-serve — any user can create one and manage its membership. + +### Team roles + +| Role | Capabilities | +| --- | --- | +| `team_member` | Belongs to the team; can use resources shared with the team. | +| `team_admin` | Manages membership and team settings. Implies `team_member`. | + +The team **owner** is a distinct concept from `team_admin`: ownership can be transferred, and a global `admin` is treated as a superuser over every team. + +### Managing a team + +| Method | Path | Description | +| --- | --- | --- | +| `GET` / `POST` | `/api/teams` | List your teams / create a team. | +| `GET` / `PATCH` / `DELETE` | `/api/teams/` | Read, update, or delete a team. | +| `GET` / `POST` | `/api/teams//members` | List members / add a member. | +| `PATCH` / `DELETE` | `/api/teams//members/` | Change a member's role / remove (or leave). | +| `POST` | `/api/teams//transfer_owner` | Transfer ownership. | + +Membership and removal are guarded so a team can never be left without an admin (the last-admin guard). + + +Members are added by email (or subject id). The invitee **must have signed in at least once** so DocsGPT can resolve them to a user account. + + +## Sharing resources with a team + +Four resource types can be shared: **agents**, **sources**, **prompts**, and **tools**. Sharing is **additive** — it grants access to others without changing ownership. + +| Method | Path | Description | +| --- | --- | --- | +| `GET` / `POST` / `DELETE` | `/api/teams//grants` | List, create, or revoke a share within a team. | +| `GET` | `/api/resource_shares` | List shares visible to the caller. | + +Sharing rules: + +- Only the **owner** of a resource can share it. +- A share targets either the **whole team** or a **single member**. +- Each share carries an access level: **`viewer`** (read-only) or **`editor`** (read and modify). +- `editor` is not the same as owner — an editor can change a resource but cannot delete it or re-share it. +- Shared tools run server-side with the **owner's** credentials; a grantee never sees the owner's secrets. + +## Audit log + +Access-control actions are appended to the `auth_events` table alongside the [authentication events](/Deploying/OIDC-SSO#login-auditing). This includes admin actions — `admin_user_activated` / `admin_user_deactivated`, `admin_sessions_revoked`, `role_granted` / `role_revoked` (with `metadata.source` = `manual` or `oidc_group`) — and team events (`team.create`, `team.member_add`, `team.member_role`, `team.member_remove`, `team.share`, `team.unshare`, `team.transfer_owner`, `team.delete`). The acting admin is recorded in the event metadata. + +## Related + +- [SSO with OIDC](/Deploying/OIDC-SSO) — sign-in, group allowlists, and the `auth_events` table. +- [App Configuration](/Deploying/DocsGPT-Settings) — the full settings reference. diff --git a/docs/content/Deploying/DocsGPT-Settings.mdx b/docs/content/Deploying/DocsGPT-Settings.mdx index c7e3595a..452ea6ed 100644 --- a/docs/content/Deploying/DocsGPT-Settings.mdx +++ b/docs/content/Deploying/DocsGPT-Settings.mdx @@ -388,6 +388,54 @@ gated by CI/CD). migration path. +## Retrieval & RAG Settings + +These control how sources are retrieved and whether the advanced RAG features are available. See [Per-Source Configuration](/Sources/Per-source-configuration) and [GraphRAG](/Sources/GraphRAG) for details. + +| Setting | Default | Description | +| --- | --- | --- | +| `RETRIEVERS_ENABLED` | `["classic", "default"]` | Allow-list of retrievers usable instance-wide. Valid keys: `classic`, `default`, `hybrid`, `graphrag`. A per-source `retriever` must be within this list. | +| `PER_SOURCE_RETRIEVAL_ENABLED` | `true` | Master switch for per-source retrieval config. When `false`, all sources fall back to the classic retriever regardless of their stored config. | +| `GRAPHRAG_ENABLED` | `false` | Enable [GraphRAG](/Sources/GraphRAG). Requires `VECTOR_STORE=pgvector`. | +| `GRAPHRAG_EXTRACTION_MODEL` | unset | Model used for ingest-time graph extraction. Unset reuses the instance default model. | +| `GRAPHRAG_MAX_CHUNKS_FOR_EXTRACTION` | `2000` | Hard cap on chunks extracted per source (cost control). | + +## Embeddings Settings + +See [Embeddings](/Models/embeddings) for full guidance. + +| Setting | Default | Description | +| --- | --- | --- | +| `EMBEDDINGS_NAME` | `huggingface_sentence-transformers/all-mpnet-base-v2` | The embedding model. | +| `EMBEDDINGS_BASE_URL` | unset | Base URL of a remote OpenAI-compatible embeddings server. Setting it routes all embedding calls there. | +| `EMBEDDINGS_KEY` | unset | Optional bearer token for the remote embeddings server. | +| `EMBEDDINGS_MAX_INPUT_TOKENS` | unset | Truncate each remote embedding input to N tokens (guards servers that reject oversized inputs). | + +## Tools Settings + +| Setting | Default | Description | +| --- | --- | --- | +| `DEFAULT_CHAT_TOOLS` | `["memory", "read_webpage", "scheduler"]` | Tools enabled automatically in regular (agentless) chats. See [Tools Basics](/Tools/basics#default-chat-tools). | + +## Admin & Access Settings + +See [Access Control, Roles & Teams](/Deploying/Access-Control) for the full model. + +| Setting | Default | Description | +| --- | --- | --- | +| `OIDC_ADMIN_GROUPS` | unset | Comma-separated IdP groups granted the global `admin` role (OIDC only). | +| `LOCAL_MODE_ADMIN` | `false` | Grants admin in no-auth mode (`AUTH_TYPE=None`) only. **Never enable on a networked deployment.** | + +## LLM Provider Settings + +| Setting | Default | Description | +| --- | --- | --- | +| `OPENAI_RESPONSES_STORE` | `false` | When `true`, allows OpenAI to persist [Responses API](/Models/cloud-providers#openai-responses-api-and-reasoning) state server-side. | + +## Realtime Events Settings + +The realtime notifications channel has its own settings — see [Realtime Events & Notifications](/Agents/notifications) (`ENABLE_SSE_PUSH`, `EVENTS_STREAM_MAXLEN`, `SSE_MAX_CONCURRENT_PER_USER`, and related). + ## Exploring More Settings These are just the basic settings to get you started. The `settings.py` file contains many more advanced options that you can explore to further customize DocsGPT, such as: diff --git a/docs/content/Deploying/OIDC-SSO.mdx b/docs/content/Deploying/OIDC-SSO.mdx index 04c44d1b..377ea929 100644 --- a/docs/content/Deploying/OIDC-SSO.mdx +++ b/docs/content/Deploying/OIDC-SSO.mdx @@ -43,6 +43,7 @@ Sessions last `OIDC_SESSION_LIFETIME_SECONDS` (8 hours by default) and renew wit | `OIDC_SESSION_LIFETIME_SECONDS` | no | `28800` (8h) | Lifetime of the DocsGPT session JWT. Sessions renew before expiry — see [Silent session renewal](#silent-session-renewal). | | `OIDC_PROVIDER_NAME` | no | — | Display name on the sign-in button: `Acme SSO` renders "Sign in with Acme SSO". Unset, the button shows a generic "SSO". | | `OIDC_ALLOWED_GROUPS` | no | — | Comma-separated group allowlist. Unset, any authenticated IdP user may sign in — see [Restricting sign-in by group](#restricting-sign-in-by-group). | +| `OIDC_ADMIN_GROUPS` | no | — | Comma-separated groups whose members are granted the global `admin` role. Re-checked at every login and renewal — see [Granting admin via groups](#granting-admin-via-groups). | | `OIDC_GROUPS_CLAIM` | no | `groups` | ID-token/userinfo claim carrying the user's group membership. | | `JWT_SECRET_KEY` | recommended | auto-generated | Signs DocsGPT session tokens. Set it explicitly in production — required when running multiple API replicas. | @@ -108,6 +109,16 @@ Getting groups into the token: - **Authentik** includes group names in the `groups` claim through its default `profile` scope — no extra configuration needed. - **Keycloak** does not emit groups by default. On the client, open **Client scopes → the client's dedicated scope → Add mapper → By configuration → Group Membership**, set the claim name to `groups`, and turn **Full group path** off so the claim carries plain names (`devs`) rather than paths (`/devs`). +## Granting admin via groups + +Separately from *who may sign in*, you can map an IdP group to the global **admin** role with `OIDC_ADMIN_GROUPS`: + +```env +OIDC_ADMIN_GROUPS=platform-admins +``` + +Members of the listed groups are granted admin; the mapping is re-evaluated at every login **and** every [silent renewal](#silent-session-renewal), so removing a user from the admin group revokes their admin at the next renewal (exactly like the sign-in allowlist). It is independent of `OIDC_ALLOWED_GROUPS`, and leaving it unset never mass-revokes admin. On a fresh deployment this is the only way to create the first admin. For the full roles, teams, and admin-dashboard model see [Access Control, Roles & Teams](/Deploying/Access-Control). + ## Silent session renewal The DocsGPT session JWT lives for `OIDC_SESSION_LIFETIME_SECONDS` (default 8 hours). Sessions renew without user-visible interruptions, in one of two ways: @@ -183,6 +194,12 @@ Authentication activity is recorded in `auth_events`, an append-only Postgres ta | `oidc_refresh` | A session is silently renewed. | | `backchannel_logout` | The IdP revokes sessions via back-channel logout. | | `scim_created` / `scim_deactivated` / `scim_reactivated` | SCIM lifecycle changes. | +| `role_granted` / `role_revoked` | The admin role is granted/revoked (`metadata.source` is `manual` or `oidc_group`). | +| `admin_user_activated` / `admin_user_deactivated` | An admin activates/deactivates a user. | +| `admin_sessions_revoked` | An admin force-logs-out a user. | +| `team.*` | Team management — `team.create`, `team.member_add`, `team.member_role`, `team.member_remove`, `team.share`, `team.unshare`, `team.transfer_owner`, `team.delete`. | + +The acting admin is recorded in the event metadata. See [Access Control, Roles & Teams](/Deploying/Access-Control) for the admin and team features that emit these events. There is no UI for these events yet — query the table directly: diff --git a/docs/content/Deploying/_meta.js b/docs/content/Deploying/_meta.js index 40d55a36..5f414192 100644 --- a/docs/content/Deploying/_meta.js +++ b/docs/content/Deploying/_meta.js @@ -7,6 +7,10 @@ export default { "title": "🔐 SSO with OIDC", "href": "/Deploying/OIDC-SSO" }, + "Access-Control": { + "title": "👥 Access Control & Teams", + "href": "/Deploying/Access-Control" + }, "Docker-Deploying": { "title": "🛳️ Docker Setup", "href": "/Deploying/Docker-Deploying" diff --git a/docs/content/Guides/Architecture.mdx b/docs/content/Guides/Architecture.mdx index c858812c..3eb53678 100644 --- a/docs/content/Guides/Architecture.mdx +++ b/docs/content/Guides/Architecture.mdx @@ -68,6 +68,16 @@ flowchart LR * Provides storage and indexing of high-dimensional vector embeddings. * Enables editing and updating of vector indexes including specific chunks. +### 4a. Retrieval Layer (Part of backend) + +* **Responsibility:** Sits between the API and the vector stores and decides *how* context is fetched for each question. Rather than a single similarity search, a **retriever dispatcher** groups the question's sources by their configured retriever and runs each under a shared token budget, then applies post-retrieval stages before answering. +* **Key Features:** + * Multiple retrievers: `classic` (vector similarity), `hybrid` (vector + full-text keyword fusion), and `graphrag` (knowledge-graph / Personalized PageRank). + * Optional query rephrasing before retrieval. + * Optional post-retrieval stages such as LLM pre-screening (map-reduce relevance filtering). + * Per-source behavior via the [per-source configuration](/Sources/Per-source-configuration) contract, so different sources can be chunked and retrieved differently. + * See [GraphRAG](/Sources/GraphRAG) for the knowledge-graph retrieval path. + ### 5. Parser Integration Layer (Part of backend) * **Technology:** Supports multiple formats for file processing and remote source uploading. @@ -101,14 +111,17 @@ sequenceDiagram participant User participant Frontend participant BackendAPI + participant RetrievalLayer participant LLMIntegrationLayer participant VectorStores participant InferenceEngine User->>Frontend: User asks a question Frontend->>BackendAPI: API Request (Question) - BackendAPI->>VectorStores: Fetch relevant document chunks (Similarity Search) - VectorStores-->>BackendAPI: Return document chunks + BackendAPI->>RetrievalLayer: Dispatch retrieval (per-source: classic / hybrid / graphrag) + RetrievalLayer->>VectorStores: Fetch candidates (vector / keyword / graph) + VectorStores-->>RetrievalLayer: Return candidates + RetrievalLayer-->>BackendAPI: Ranked, optionally pre-screened chunks BackendAPI->>LLMIntegrationLayer: Send question and document chunks LLMIntegrationLayer->>InferenceEngine: LLM API Request (Prompt + Context) InferenceEngine-->>LLMIntegrationLayer: LLM API Response (Answer) diff --git a/docs/content/Models/cloud-providers.mdx b/docs/content/Models/cloud-providers.mdx index 23d0fd44..1ab730e1 100644 --- a/docs/content/Models/cloud-providers.mdx +++ b/docs/content/Models/cloud-providers.mdx @@ -25,13 +25,17 @@ DocsGPT offers direct, streamlined support for the following cloud LLM providers | :--------------------------- | :------------- | :-------------------------- | | DocsGPT Public API | `docsgpt` | `None` | | OpenAI | `openai` | `gpt-5.1` | +| OpenAI-compatible (BYOM) | `openai_compatible` | (any; with per-model `base_url`/`api_key`) | | Google (Vertex AI, Gemini) | `google` | `gemini-3.5-flash` | | Anthropic (Claude) | `anthropic` | `claude-3-5-sonnet-20241022`| | Groq | `groq` | `llama-3.3-70b-versatile` | +| OpenRouter | `openrouter` | (See OpenRouter docs) | +| Novita AI | `novita` | (See Novita docs) | | HuggingFace Inference API | `huggingface` | `meta-llama/Llama-3.1-8B-Instruct` | | Prem AI | `premai` | (See Prem AI docs) | | AWS SageMaker | `sagemaker` | (See SageMaker docs) | -| Novita AI | `novita` | (See Novita docs) | + +DocsGPT also ships a **model catalog** (`application/core/models/*.yaml`) that the in-app model picker reads, so common models from these providers — including DeepSeek — appear ready to select once the matching API key is set. ## Connecting to OpenAI-Compatible Cloud APIs @@ -52,6 +56,21 @@ OPENAI_BASE_URL=https://api.deepseek.com/v1 # DeepSeek's OpenAI API URL Remember to consult the documentation of your chosen OpenAI-compatible cloud provider for their specific API endpoint, required model names, and authentication methods. +### Dedicated `openai_compatible` provider (bring-your-own-model) + +Beyond the global `OPENAI_BASE_URL`, DocsGPT has a first-class `openai_compatible` provider. It lets a model carry its **own** `base_url` and `api_key`, which is how per-user "bring your own model" (BYOM) endpoints work — each model can point at a different OpenAI-compatible server without changing instance-wide settings. Outbound requests use an SSRF-pinned HTTP client for safety. + +This is the mechanism behind catalog entries like DeepSeek, which declare their own `base_url` and API key environment variable rather than relying on `OPENAI_BASE_URL`. + +## OpenAI Responses API and reasoning + +For OpenAI models that support it, DocsGPT can call the newer **Responses API** (`/v1/responses`) instead of Chat Completions. This is selected per model in the catalog via an `api_flavor: responses` capability and enables features like server-side reasoning. Related settings: + +- `reasoning_effort` — per-model reasoning effort hint (for example `medium`) declared in the model catalog. +- `OPENAI_RESPONSES_STORE` (default `false`) — when `true`, lets OpenAI persist Responses API state server-side. + +See [App Configuration](/Deploying/DocsGPT-Settings) for the full settings reference. + ## Adding Support for Other Cloud Providers If you wish to connect to a cloud provider that is not explicitly listed above or doesn't offer OpenAI API compatibility, you can extend DocsGPT to support it. Within the DocsGPT repository, navigate to the `application/llm` directory. Here, you will find Python files defining the existing LLM integrations. You can use these files as examples to create a new module for your desired cloud provider. After creating your new LLM module, you will need to register it within the `llm_creator.py` file. This process involves some coding, but it allows for virtually unlimited extensibility to connect to any cloud-based LLM service with an accessible API. \ No newline at end of file diff --git a/docs/content/Models/embeddings.md b/docs/content/Models/embeddings.md index 68015db7..54ef2e2b 100644 --- a/docs/content/Models/embeddings.md +++ b/docs/content/Models/embeddings.md @@ -22,10 +22,12 @@ In essence, embedding models are the bridge that allows DocsGPT to understand th ## Out-of-the-Box Embedding Model Support in DocsGPT -DocsGPT is designed to be flexible and supports a wide range of embedding models right out of the box. Currently, DocsGPT provides native support for models from two major sources: +DocsGPT is designed to be flexible and supports a wide range of embedding models right out of the box: -* **Sentence Transformers:** DocsGPT supports all models available through the [Sentence Transformers library](https://www.sbert.net/). This library offers a vast selection of pre-trained embedding models, known for their quality and efficiency in various semantic tasks. -* **OpenAI Embeddings:** DocsGPT also supports using embedding models from OpenAI, specifically the `text-embedding-ada-002` model, which is a powerful and widely used embedding model from OpenAI's API. +* **Sentence Transformers:** DocsGPT supports all models available through the [Sentence Transformers library](https://www.sbert.net/). This library offers a vast selection of pre-trained embedding models, known for their quality and efficiency in various semantic tasks. This is the default (`EMBEDDINGS_NAME=huggingface_sentence-transformers/all-mpnet-base-v2`). +* **OpenAI Embeddings:** DocsGPT supports OpenAI embedding models (for example `text-embedding-ada-002`, `text-embedding-3-small`, `text-embedding-3-large`) via the OpenAI API. +* **Azure OpenAI Embeddings:** Set `AZURE_EMBEDDINGS_DEPLOYMENT_NAME` alongside your Azure OpenAI configuration. +* **Remote OpenAI-compatible Embeddings:** Any server that exposes an OpenAI-compatible `/v1/embeddings` endpoint (for example llama.cpp, vLLM, TEI, or a hosted provider) by setting `EMBEDDINGS_BASE_URL`. See [Remote Embeddings](#remote-openai-compatible-embeddings) below. ## Configuring Sentence Transformer Models @@ -65,6 +67,36 @@ API_KEY=YOUR_OPENAI_API_KEY # Your OpenAI API Key EMBEDDINGS_NAME=openai_text-embedding-ada-002 ``` +## Remote (OpenAI-compatible) Embeddings + +If you run your own embedding server, or use a provider that exposes an OpenAI-style embeddings API, point DocsGPT at it with `EMBEDDINGS_BASE_URL`. When this is set, all embedding calls (ingestion and querying) are sent to `{EMBEDDINGS_BASE_URL}/v1/embeddings` in OpenAI format instead of running a local model. + +```env +EMBEDDINGS_BASE_URL=http://localhost:8080 # your OpenAI-compatible embeddings server +EMBEDDINGS_NAME=your-model-name # sent as the "model" field in the request +EMBEDDINGS_KEY=YOUR_API_KEY # optional; sent as a Bearer token +``` + +- `EMBEDDINGS_BASE_URL` — base URL of the remote server. Setting it switches DocsGPT into remote-embeddings mode. +- `EMBEDDINGS_NAME` — forwarded as the `model` field in each request. +- `EMBEDDINGS_KEY` — optional bearer token. If you are using OpenAI directly you can copy `API_KEY` here. + +### Guarding against oversized inputs + +Some remote servers (notably llama.cpp) reject any single input larger than their physical batch size with a `500` error. Set `EMBEDDINGS_MAX_INPUT_TOKENS` to clip each input to a fixed number of tokens before it is sent: + +```env +EMBEDDINGS_MAX_INPUT_TOKENS=512 +``` + +When set, each input string is truncated to that many tokens and the overflow is dropped (lossy by design). Token counts use DocsGPT's shared tiktoken encoding, which differs from your server's tokenizer, so choose a limit with some headroom below the server's true limit to absorb tokenizer skew. Leave the setting unset (or `0`) to disable truncation. + +## Important: Embedding Dimensions Must Stay Consistent + +Each embedding model produces vectors of a fixed dimension, and your vector store is created with that dimension. **Changing `EMBEDDINGS_NAME` to a model with a different dimension is not compatible with an existing index** — FAISS and LanceDB will raise a dimension-mismatch error, and pgvector/Qdrant tables are sized to the original dimension. + +If you need to switch embedding models, you must re-ingest your sources so the index is rebuilt with the new dimension. This also applies to the [GraphRAG](/Sources/GraphRAG) graph tables, which are sized to the embedding dimension at creation time. + ## Adding Support for Other Embedding Models If you wish to use an embedding model that is not supported out-of-the-box, a good starting point for adding custom embedding model support is to examine the `base.py` file located in the `application/vectorstore` directory. diff --git a/docs/content/Sources/GraphRAG.mdx b/docs/content/Sources/GraphRAG.mdx new file mode 100644 index 00000000..a186e26f --- /dev/null +++ b/docs/content/Sources/GraphRAG.mdx @@ -0,0 +1,114 @@ +--- +title: GraphRAG — Knowledge-Graph Retrieval +description: Build a knowledge graph from a source at ingest time and retrieve over it with Personalized PageRank. Covers requirements, enabling, configuration, and the graph view. +--- + +import { Callout } from 'nextra/components' +import Image from 'next/image' + +# GraphRAG + +GraphRAG augments classic vector retrieval with a **knowledge graph**. During ingestion DocsGPT uses an LLM to extract entities and the relationships between them from a source's chunks, and stores them as a graph alongside the vectors. At query time, a graph retriever uses Personalized PageRank (PPR) to walk that graph from the entities mentioned in your question, surfacing connected context that pure similarity search can miss — useful for multi-hop questions and queries that span related concepts. + + +GraphRAG is **flag-gated** and currently **pgvector-only**. It is available only when both `GRAPHRAG_ENABLED=true` **and** `VECTOR_STORE=pgvector`. On any other vector store the enable action is rejected. + + +## Requirements + +- A PostgreSQL database with the `pgvector` extension (`VECTOR_STORE=pgvector`). See [PostgreSQL for User Data](/Deploying/Postgres-Migration). +- `GRAPHRAG_ENABLED=true` in your environment. +- An LLM configured for extraction (GraphRAG reuses your instance default model unless you override it). + +```env +GRAPHRAG_ENABLED=true +VECTOR_STORE=pgvector +``` + +The graph tables live in the same pgvector database as your embeddings and are sized to the embedding dimension. If you change embedding models you must re-ingest and re-extract (see [Embeddings](/Models/embeddings#important-embedding-dimensions-must-stay-consistent)). + +## How it works + +1. **Choose GraphRAG** for the source — either at upload time, or by enabling it on an existing source (see below). This sets the source's config to `graphrag` mode. +2. **Extraction** runs over the source's chunks. For each chunk, the LLM extracts entities and relations, which are written into per-source graph tables. Extraction is durable and resumable via a checkpoint, so it survives restarts and re-runs from scratch each time you re-enable it. +3. **Query.** Questions against the source are routed to the graph retriever, which runs Personalized PageRank from the query's entities to gather related context. + + +If a source has no graph yet (extraction still running or failed), the graph retriever **falls back to classic vector retrieval** for that source — answers keep working, they just don't use the graph until it is ready. + + +## Enabling GraphRAG + +### At upload time (recommended) + +When you upload a new document, open **Advanced settings** and set **Retriever** to **GraphRAG** (the same dropdown also offers **Hybrid**). The source is created in `graphrag` mode and extraction is enqueued as part of ingestion — no extra step. + +Upload dialog advanced settings showing the Retriever dropdown with Classic, Hybrid, and GraphRAG options + +These are the same [per-source retrieval settings](/Sources/Per-source-configuration) you can change later — choosing the retriever up front just avoids a re-ingest. + +### On an existing source + +To turn an already-ingested source into a GraphRAG source, use the **Enable GraphRAG** action on the source (it shows a status badge while extraction runs), or call the API: + +```bash +curl -X POST https://your-docsgpt/api/sources//graphrag/enable \ + -H "Authorization: Bearer " +``` + +The response returns a `task_id` for the extraction job: + +```json +{ "success": true, "task_id": "..." } +``` + +Notes: + +- Requires write access to the source (owner or team `editor`). +- Returns `400` if GraphRAG isn't available on the workspace (wrong vector store or flag off). +- Re-running the action rebuilds the graph from scratch rather than no-opping against an existing one. +- You cannot switch a source to `graphrag` through the [config PATCH endpoint](/Sources/Per-source-configuration#editing-the-config-via-api) — use the upload-time selector or this dedicated endpoint. + +## Configuration + +Instance-wide settings (see [App Configuration](/Deploying/DocsGPT-Settings)): + +| Setting | Default | Description | +| --- | --- | --- | +| `GRAPHRAG_ENABLED` | `false` | Master switch for the feature. | +| `GRAPHRAG_EXTRACTION_MODEL` | `null` | Model used for extraction. `null` reuses the instance default model. | +| `GRAPHRAG_MAX_CHUNKS_FOR_EXTRACTION` | `2000` | Hard cap on how many chunks are extracted per source (cost control). | + +Per-source extraction knobs live under the source config's `graph` object and override the instance defaults: + +| Field | Default | Description | +| --- | --- | --- | +| `extraction_model` | `null` | Override the extraction model for this source. | +| `max_chunks` | `null` | Override the chunk cap; `null` falls back to `GRAPHRAG_MAX_CHUNKS_FOR_EXTRACTION`. | +| `gleanings` | `0` | Extra extraction passes per chunk to catch entities missed on the first pass. Off by default (each pass costs additional LLM calls). | + + +Graph extraction makes an LLM call per chunk (more if `gleanings > 0`), so it has a real token cost. The cost is attributed to token usage under a `graph_extraction` tag, and the `max_chunks` cap bounds it. + + +## Visualizing the graph + +GraphRAG sources expose a **graph view** in the UI — an interactive network of the extracted entities and relationships. It is backed by two read endpoints: + +```text +GET /api/sources//graph # bounded {nodes, edges} overview +GET /api/sources//graph/node/ # one node and its neighbors +``` + +The overview is bounded to a default node limit to keep large graphs responsive. + +## Related + +- [Per-Source Configuration](/Sources/Per-source-configuration) — the config object GraphRAG plugs into. +- [PostgreSQL for User Data](/Deploying/Postgres-Migration) — required pgvector setup. +- [Embeddings](/Models/embeddings) — embedding-dimension constraints that also apply to the graph tables. diff --git a/docs/content/Sources/Per-source-configuration.mdx b/docs/content/Sources/Per-source-configuration.mdx new file mode 100644 index 00000000..c27ee96b --- /dev/null +++ b/docs/content/Sources/Per-source-configuration.mdx @@ -0,0 +1,177 @@ +--- +title: Per-Source Configuration (Chunking & Retrieval) +description: Tune how each knowledge source is chunked at ingest time and retrieved at query time — chunking strategy, retriever, exposure, top-k, pre-screening and more. +--- + +import { Callout } from 'nextra/components' +import Image from 'next/image' + +# Per-Source Configuration + +Every source in DocsGPT carries its own **behavior contract** — a small config object that controls how that source is *chunked* when it is ingested and how it is *retrieved* when you ask a question. This lets you tune each source independently: a large reference manual can use a different chunking strategy and retriever than a short FAQ. + +You edit this config from a source's settings in the UI (shown below), or through the API. The same options are also available in **Advanced settings** when you first upload a document. + +Source settings panel showing Retrieval options (retriever, top-k, score threshold, rephrase, exposure, prescreen) and Chunking options (strategy, max/min tokens, duplicate headers) + + +Per-source retrieval is enabled by default. Operators can turn it off instance-wide with `PER_SOURCE_RETRIEVAL_ENABLED=false`, in which case all sources fall back to the classic retriever regardless of their stored config. + + +## Two kinds of settings: live vs. bake-time + +The config has two groups of settings that differ in *when* they take effect: + +| Group | When it applies | Re-ingest needed? | +| --- | --- | --- | +| **Retrieval** (`retrieval.*`) | Query time — applied live on the next question | No | +| **Chunking** (`chunking.*`) | Ingest time — baked into the stored chunks | **Yes** | + +Changing a retrieval setting takes effect immediately. Changing a chunking setting only affects documents ingested *after* the change, so you must re-ingest the source to apply it to existing content. The API response includes a `requires_reingest` flag to make this explicit. + +## Chunking configuration + +Chunking decides how a document is split into the pieces that get embedded and stored. + +```json +{ + "chunking": { + "strategy": "classic_chunk", + "max_tokens": 1250, + "min_tokens": 150, + "duplicate_headers": false + } +} +``` + +| Field | Default | Description | +| --- | --- | --- | +| `strategy` | `classic_chunk` | Which chunking algorithm to use (see below). | +| `max_tokens` | `1250` | Upper bound on chunk size in tokens. | +| `min_tokens` | `150` | Lower bound; small fragments are merged up to this size. | +| `duplicate_headers` | `false` | Repeat section headers into each child chunk for context. | + +### Available chunking strategies + +| Strategy | Behavior | +| --- | --- | +| `classic_chunk` | The default token-window splitter. An empty config reproduces DocsGPT's historical chunking byte-for-byte. | +| `recursive` | Recursive character/token splitter that tries to break on natural boundaries (paragraphs, sentences). | +| `markdown` | Splits along Markdown structure (headings, sections) — good for docs and wikis. | +| `parent_child` | Embeds small **child** chunks for precise matching but carries a larger **parent** window in metadata, so the model still sees surrounding context. | +| `semantic` | Embeds sentences and splits where meaning shifts (at the 95th-percentile cosine-distance gap between adjacent sentences), falling back to `recursive` on failure. Produces topically coherent chunks at the cost of extra embedding calls during ingest. | + + +Chunking is bake-time. After changing `strategy`, `max_tokens`, `min_tokens`, or `duplicate_headers`, re-ingest the source so existing chunks are rebuilt. + + +## Retrieval configuration + +Retrieval decides which chunks are pulled in to answer a question. These settings apply live. + +```json +{ + "retrieval": { + "retriever": "classic", + "exposure": "prefetch", + "chunks": 2, + "score_threshold": null, + "rephrase_query": true, + "prescreen": null + } +} +``` + +| Field | Default | Description | +| --- | --- | --- | +| `retriever` | `classic` | Retrieval strategy: `classic`, `hybrid`, or `graphrag`. | +| `exposure` | `prefetch` | How retrieved context reaches the model: `prefetch` or `agentic_tool` (see below). | +| `chunks` | `2` | Final number of chunks (top-k) returned to the answer. Range 1–500. | +| `score_threshold` | `null` | Minimum similarity score. Honored by pgvector and MongoDB Atlas; other stores ignore it. | +| `rephrase_query` | `true` | Whether to run a query-rephrasing side-call before retrieval. | +| `prescreen` | `null` | Optional LLM relevance filter (see below). `null` = off. | + +### Retrievers + +- **`classic`** — Vector similarity search. The default and a safe choice for any vector store. +- **`hybrid`** — Fuses vector search with full-text keyword search using Reciprocal Rank Fusion, which improves recall for exact terms, codes, and names that pure vector search can miss. +- **`graphrag`** — Knowledge-graph retrieval. Set indirectly when you enable GraphRAG on a source. See [GraphRAG](/Sources/GraphRAG). + + +Keyword search for the **hybrid** retriever is currently implemented only for the **pgvector** vector store. On other stores (FAISS, Qdrant, Milvus, etc.) the keyword half returns nothing, so `hybrid` quietly behaves like `classic` (vector-only). + + +Operators can restrict which retrievers are usable instance-wide with the `RETRIEVERS_ENABLED` setting; a per-source `retriever` value must be within that allow-list. + +### Exposure: prefetch vs. agentic tool + +`exposure` controls *how* a source's content is delivered to the model: + +- **`prefetch`** (default) — DocsGPT retrieves the top chunks up front and injects them into the prompt before the model answers. Best for focused Q&A over a source. +- **`agentic_tool`** — The source is exposed to the model as a search tool it can call on demand, deciding when and what to look up (browse-as-you-go) rather than receiving a bulk prefetch. This is the default exposure for [Wiki sources](/Sources/Wiki-sources). + +### Pre-screening (LLM relevance filter) + +Pre-screening adds an optional map-reduce step between retrieval and answering: a base retriever fetches a wider set of candidates, an LLM screens them in batches, and only the most relevant survivors are passed to the answer. It improves precision on noisy sources at the cost of extra query-time LLM calls, so it is **off by default**. + +```json +{ + "retrieval": { + "chunks": 8, + "prescreen": { + "candidate_k": 40, + "batch_size": 10, + "max_keep": 8, + "model": null + } + } +} +``` + +| Field | Default | Description | +| --- | --- | --- | +| `candidate_k` | `40` | Candidates fetched before screening. Must be `>= chunks`. | +| `batch_size` | `10` | Candidates screened per LLM call. | +| `max_keep` | `8` | Survivors kept after screening. Must be `<= candidate_k`. | +| `model` | `null` | Model used for screening. `null` reuses the request's resolved model. | + +## Editing the config via API + +The config is edited with a `PATCH` to the source's config endpoint: + +```bash +curl -X PATCH https://your-docsgpt/api/sources//config \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "retrieval": { "retriever": "hybrid", "chunks": 4 }, + "chunking": { "strategy": "semantic" } + }' +``` + +The response echoes the stored config and a `requires_reingest` flag: + +```json +{ + "success": true, + "config": { "...": "..." }, + "requires_reingest": true +} +``` + +Notes: + +- Invalid values are rejected with `400` (strict validation on write). +- The `kind` field (classic / wiki / graphrag) cannot be changed through this endpoint — converting a source to a [Wiki](/Sources/Wiki-sources) or enabling [GraphRAG](/Sources/GraphRAG) uses dedicated endpoints. +- Editing requires ownership of the source or a team `editor` grant; viewers receive `403`. + +## Related + +- [GraphRAG](/Sources/GraphRAG) — knowledge-graph retrieval for a source. +- [Wiki Sources](/Sources/Wiki-sources) — LLM-editable living documentation. +- [Embeddings](/Models/embeddings) — the embedding model used during ingest and retrieval. diff --git a/docs/content/Sources/Wiki-sources.mdx b/docs/content/Sources/Wiki-sources.mdx new file mode 100644 index 00000000..04c8b568 --- /dev/null +++ b/docs/content/Sources/Wiki-sources.mdx @@ -0,0 +1,96 @@ +--- +title: Wiki Sources — Living, LLM-Editable Documentation +description: Create a knowledge source that the agent can read and write — a living wiki it keeps up to date, with human edits, provenance stamps, and version safety. +--- + +import { Callout } from 'nextra/components' + +# Wiki Sources + +A **wiki source** is a knowledge source that the agent can both read *and* write. Instead of being a fixed set of ingested files, a wiki is a small set of Markdown pages that the LLM edits over time — recording what it learns, correcting stale information, and building living documentation. Humans can edit the same pages directly, and every change is stamped with who made it. + +Unlike a classic source, a wiki is **team-scoped, not per-user**: it is shared and edited at the source level, so a whole team works against the same living document. + +## How a wiki differs from a classic source + +| | Classic source | Wiki source | +| --- | --- | --- | +| Content | Ingested files, read-only | Markdown pages, read **and** write | +| Who edits | You (re-upload to change) | The agent and humans | +| Default exposure | `prefetch` (chunks injected up front) | `agentic_tool` (the agent browses pages on demand) | +| Searchability | Embedded at ingest | Re-embedded automatically on every edit | +| Scope | Per owner | Team-shareable, edited at source scope | + +Because a wiki defaults to the `agentic_tool` [exposure](/Sources/Per-source-configuration#exposure-prefetch-vs-agentic-tool), the agent navigates it as a tool — opening, searching, and editing pages as needed — rather than receiving a bulk prefetch. + +## How the agent edits a wiki + +The agent edits a wiki through an internal **Wiki tool** that is automatically scoped to one wiki source. It supports a small, edit-safe action surface: + +- **view** a page, +- **create** or overwrite a page, +- **str_replace** an exact, unique string, +- **insert** at a line, +- **delete** a page, +- **rename** a page. + +Two safety properties matter: + +- **Provenance stamps.** Every page records whether its last change came from a human or the agent, so edits are traceable. +- **Optimistic versioning.** Edits carry an expected version; if a page changed underneath, the edit is rejected rather than silently clobbering a concurrent change. String replacements must match exactly and uniquely. + +After any edit, the affected page is **re-embedded asynchronously** so the wiki stays searchable and the new content is immediately retrievable. + + +Each wiki page is capped at 1 MB (1,000,000 bytes). Pages are addressed by path (the home page is `/index.md`). + + +## Creating a wiki + +In the UI, choose **New wiki source** when adding a source. From the API: + +```bash +curl -X POST https://your-docsgpt/api/sources/wiki \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ "name": "Team Handbook", "initial_content": "# Team Handbook\n\nWelcome." }' +``` + +- `name` (required) — the wiki source name. +- `initial_content` (optional) — Markdown that seeds the home page `/index.md` and triggers its first re-embed. + +No ingestion task runs for a wiki; pages are authored directly. The response returns the new `source_id`. + +## Converting an existing source into a wiki + +You can turn an already-ingested source into a wiki so the agent can start maintaining it: + +```bash +curl -X POST https://your-docsgpt/api/sources//wiki/convert \ + -H "Authorization: Bearer " +``` + +- A **blank** source is enabled inline (no task) and immediately becomes a wiki. +- A source **with files** runs a conversion task that reassembles its existing chunks into wiki pages; poll the returned task for a per-file summary. +- Conversion is rejected with `409` if the source is still ingesting — wait for it to finish first. + + +Switching a source to (or from) wiki mode goes only through `POST /api/sources//wiki/convert`. It cannot be done through the [config PATCH endpoint](/Sources/Per-source-configuration#editing-the-config-via-api). + + +## Reading and editing pages directly + +Humans can read and edit wiki pages through the API (and the wiki viewer in the UI): + +```text +GET /api/sources//wiki/pages # list pages +GET /api/sources//wiki/page?path=... # fetch one page fresh +PUT /api/sources//wiki/page # create or overwrite a page (human edit) +``` + +Human edits are stamped with `human` provenance and trigger the same re-embed as agent edits. Read access follows source sharing (owner or anyone the source is shared with); writing requires owner or team `editor` access. + +## Related + +- [Per-Source Configuration](/Sources/Per-source-configuration) — exposure and retrieval settings a wiki uses. +- [Access Control & Teams](/Deploying/Access-Control) — sharing a wiki with a team. diff --git a/docs/content/Sources/_meta.js b/docs/content/Sources/_meta.js new file mode 100644 index 00000000..06a1d99a --- /dev/null +++ b/docs/content/Sources/_meta.js @@ -0,0 +1,14 @@ +export default { + "Per-source-configuration": { + "title": "🎛️ Per-Source Configuration", + "href": "/Sources/Per-source-configuration" + }, + "GraphRAG": { + "title": "🕸️ GraphRAG", + "href": "/Sources/GraphRAG" + }, + "Wiki-sources": { + "title": "📖 Wiki Sources", + "href": "/Sources/Wiki-sources" + } +} diff --git a/docs/content/Tools/basics.mdx b/docs/content/Tools/basics.mdx index 66ef2e71..133e5d3d 100644 --- a/docs/content/Tools/basics.mdx +++ b/docs/content/Tools/basics.mdx @@ -37,38 +37,82 @@ DocsGPT includes a suite of pre-built tools designed to expand its capabilities description: 'A highly flexible tool that allows DocsGPT to interact with virtually any API without needing to write custom Python code.' }, { - title: 'Brave Search Tool', + title: 'Brave Search', link: 'https://github.com/arc53/DocsGPT/blob/main/application/agents/tools/brave.py', - description: 'Enables DocsGPT to perform real-time web and image searches using the Brave Search API for up-to-date information.' + description: 'Enables DocsGPT to perform real-time web and image searches using the Brave Search API. Requires an API key.' }, { - title: 'Cryptoprice Tool', + title: 'DuckDuckGo Search', + link: 'https://github.com/arc53/DocsGPT/blob/main/application/agents/tools/duckduckgo.py', + description: 'Performs web and image searches using DuckDuckGo. No API key required.' + }, + { + title: 'CryptoPrice', link: 'https://github.com/arc53/DocsGPT/blob/main/application/agents/tools/cryptoprice.py', - description: 'Fetches the current price of specified cryptocurrencies.' + description: 'Fetches the current price of specified cryptocurrencies using the CryptoCompare public API.' }, { - title: 'Ntfy Tool', + title: 'Ntfy', link: 'https://github.com/arc53/DocsGPT/blob/main/application/agents/tools/ntfy.py', - description: 'Allows DocsGPT to send push notifications to Ntfy.sh channels, ideal for alerts and updates.' + description: 'Allows DocsGPT to send push notifications to ntfy topics on a specified server, ideal for alerts and updates.' }, { - title: 'PostgreSQL Tool', - link: 'https://github.com/arc53/DocsGPT/blob/main/application/agents/tools/postgres.py', - description: 'Provides capabilities to connect to a PostgreSQL database, execute SQL queries, and retrieve schema information.' - }, - { - title: 'Read Webpage Tool', // Renamed from Scraper Tool - link: 'https://github.com/arc53/DocsGPT/blob/main/application/agents/tools/read_webpage.py', - description: 'Enables DocsGPT to fetch and extract (scrape) textual content from specified web page URLs.' - }, - { - title: 'Telegram Tool', + title: 'Telegram Bot', link: 'https://github.com/arc53/DocsGPT/blob/main/application/agents/tools/telegram.py', - description: 'Allows DocsGPT to send messages or images to Telegram chats via a Telegram Bot.' + description: 'Allows DocsGPT to send messages or images to Telegram chats via a Telegram Bot. Requires a bot token and chat ID.' + }, + { + title: 'PostgreSQL Database', + link: 'https://github.com/arc53/DocsGPT/blob/main/application/agents/tools/postgres.py', + description: 'Connects to a PostgreSQL database to execute SQL queries and retrieve schema information.' + }, + { + title: 'Read Webpage (browser)', + link: 'https://github.com/arc53/DocsGPT/blob/main/application/agents/tools/read_webpage.py', + description: 'Fetches the HTML content of a URL and converts it to Markdown for the agent to read.' + }, + { + title: 'Remote Device', + link: '/Tools/remote-device', + description: 'Runs shell commands on a paired remote machine through the docsgpt-cli host. See the Remote Device guide.' + }, + { + title: 'MCP Tool', + link: '/Guides/Integrations/mcp-tool-integration', + description: 'Connects to remote Model Context Protocol (MCP) servers to access their dynamic tools and resources.' + }, + { + title: 'Memory', + link: 'https://github.com/arc53/DocsGPT/blob/main/application/agents/tools/memory.py', + description: 'Stores and retrieves information across conversations through a per-user memory file directory.' + }, + { + title: 'Notepad', + link: 'https://github.com/arc53/DocsGPT/blob/main/application/agents/tools/notes.py', + description: 'A single editable note. Supports viewing, overwriting, and string replacement.' + }, + { + title: 'Todo List', + link: 'https://github.com/arc53/DocsGPT/blob/main/application/agents/tools/todo_list.py', + description: 'Manages todo items — creating, viewing, updating, and deleting todos.' } ]} /> +## Default Chat Tools + +In a regular chat (no custom agent), DocsGPT can enable a small set of tools automatically so the assistant is useful out of the box. These are the **default chat tools**, controlled by the `DEFAULT_CHAT_TOOLS` setting: + +```env +DEFAULT_CHAT_TOOLS=memory,read_webpage,scheduler +``` + +- Default tools are config-free and run with synthetic, deterministic tool IDs (no manual setup needed). +- Each user can opt out of individual default tools from their settings; the disabled list is stored per user. +- Some default tools are excluded from **headless runs** (scheduled tasks and webhook triggers). For example, `scheduler` is skipped in those runs to prevent a scheduled task from chaining new schedules on every fire. + +To change the defaults for the whole instance, set `DEFAULT_CHAT_TOOLS` to a comma-separated list of tool names. See [App Configuration](/Deploying/DocsGPT-Settings) for the full settings reference. + ## Using Tools in DocsGPT (User Perspective) Interacting with tools in DocsGPT is designed to be intuitive: diff --git a/docs/content/_meta.js b/docs/content/_meta.js index bbe5d9f9..c5e71a9f 100644 --- a/docs/content/_meta.js +++ b/docs/content/_meta.js @@ -4,6 +4,7 @@ export default { "upgrading": "Upgrading", "Deploying": "Deploying", "Models": "Models", + "Sources": "Sources", "Tools": "Tools", "Agents": "Agents", "Extensions": "Extensions", diff --git a/docs/public/graph-rag-settings-before-upload.png b/docs/public/graph-rag-settings-before-upload.png new file mode 100644 index 00000000..c68edea8 Binary files /dev/null and b/docs/public/graph-rag-settings-before-upload.png differ diff --git a/docs/public/sources-settings-screen.png b/docs/public/sources-settings-screen.png new file mode 100644 index 00000000..d9c1faf1 Binary files /dev/null and b/docs/public/sources-settings-screen.png differ diff --git a/docs/public/toolIcons/tool_duckduckgo.svg b/docs/public/toolIcons/tool_duckduckgo.svg new file mode 100644 index 00000000..8215a918 --- /dev/null +++ b/docs/public/toolIcons/tool_duckduckgo.svg @@ -0,0 +1 @@ +duckduckgo \ No newline at end of file diff --git a/docs/public/toolIcons/tool_mcp_tool.svg b/docs/public/toolIcons/tool_mcp_tool.svg new file mode 100644 index 00000000..88f760b8 --- /dev/null +++ b/docs/public/toolIcons/tool_mcp_tool.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/docs/public/toolIcons/tool_memory.svg b/docs/public/toolIcons/tool_memory.svg new file mode 100644 index 00000000..cd526397 --- /dev/null +++ b/docs/public/toolIcons/tool_memory.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/public/toolIcons/tool_notes.svg b/docs/public/toolIcons/tool_notes.svg new file mode 100644 index 00000000..7a8d0bba --- /dev/null +++ b/docs/public/toolIcons/tool_notes.svg @@ -0,0 +1 @@ + diff --git a/docs/public/toolIcons/tool_remote_device.svg b/docs/public/toolIcons/tool_remote_device.svg new file mode 100644 index 00000000..b9b06ce4 --- /dev/null +++ b/docs/public/toolIcons/tool_remote_device.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/docs/public/toolIcons/tool_todo_list.svg b/docs/public/toolIcons/tool_todo_list.svg new file mode 100644 index 00000000..3eb8ef24 --- /dev/null +++ b/docs/public/toolIcons/tool_todo_list.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/Navigation.tsx b/frontend/src/Navigation.tsx index 3cd144db..efdc07c3 100644 --- a/frontend/src/Navigation.tsx +++ b/frontend/src/Navigation.tsx @@ -571,14 +571,14 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { )} {conversations?.data && conversations.data.length > 0 ? (
-
+

{t('chats')}

+ + {showNodeConfig && selectedNode && ( diff --git a/frontend/src/agents/workflow/hooks/useUndoRedo.ts b/frontend/src/agents/workflow/hooks/useUndoRedo.ts new file mode 100644 index 00000000..6c5ed249 --- /dev/null +++ b/frontend/src/agents/workflow/hooks/useUndoRedo.ts @@ -0,0 +1,146 @@ +import { useCallback, useRef, useState } from 'react'; + +import type { Dispatch, SetStateAction } from 'react'; +import type { Edge, Node } from 'reactflow'; + +export type WorkflowSnapshot = { + nodes: Node[]; + edges: Edge[]; +}; + +type UseUndoRedoOptions = { + nodes: Node[]; + edges: Edge[]; + setNodes: Dispatch>; + setEdges: Dispatch>; + onRestore?: (snapshot: WorkflowSnapshot) => void; + maxHistorySize?: number; +}; + +const SNAPSHOT_GROUP_WINDOW_MS = 1000; + +// Selection is not part of history: restored elements keep whatever +// selection the user has right now instead of the flags baked into the +// snapshot when it was taken. +function withCurrentSelection( + restored: T[], + current: T[], +): T[] { + const selectedIds = new Set( + current.filter((el) => el.selected).map((el) => el.id), + ); + return restored.map((el) => + Boolean(el.selected) === selectedIds.has(el.id) + ? el + : { ...el, selected: selectedIds.has(el.id) }, + ); +} + +export function useUndoRedo({ + nodes, + edges, + setNodes, + setEdges, + onRestore, + maxHistorySize = 100, +}: UseUndoRedoOptions) { + const [past, setPast] = useState([]); + const [future, setFuture] = useState([]); + + const stateRef = useRef({ nodes, edges }); + stateRef.current = { nodes, edges }; + const pastRef = useRef(past); + pastRef.current = past; + const futureRef = useRef(future); + futureRef.current = future; + const onRestoreRef = useRef(onRestore); + onRestoreRef.current = onRestore; + + // Consecutive calls sharing a group key (e.g. keystrokes editing one + // node's config) collapse into a single history entry while each call + // stays inside the group window. + const lastGroupKeyRef = useRef(null); + const lastGroupTimeRef = useRef(0); + // One user action can request several snapshots in the same event tick + // (deleting a node also deletes its edges); only the first one counts. + const tickGuardRef = useRef(false); + + const takeSnapshot = useCallback( + (groupKey?: string) => { + const now = Date.now(); + if ( + groupKey && + groupKey === lastGroupKeyRef.current && + now - lastGroupTimeRef.current < SNAPSHOT_GROUP_WINDOW_MS + ) { + lastGroupTimeRef.current = now; + return; + } + lastGroupKeyRef.current = groupKey ?? null; + lastGroupTimeRef.current = now; + + if (tickGuardRef.current) return; + tickGuardRef.current = true; + queueMicrotask(() => { + tickGuardRef.current = false; + }); + + const snapshot: WorkflowSnapshot = { + nodes: stateRef.current.nodes, + edges: stateRef.current.edges, + }; + setPast((prev) => + prev.length >= maxHistorySize + ? [...prev.slice(prev.length - maxHistorySize + 1), snapshot] + : [...prev, snapshot], + ); + setFuture([]); + }, + [maxHistorySize], + ); + + const undo = useCallback(() => { + const previous = pastRef.current[pastRef.current.length - 1]; + if (!previous) return; + const current: WorkflowSnapshot = { + nodes: stateRef.current.nodes, + edges: stateRef.current.edges, + }; + lastGroupKeyRef.current = null; + setPast(pastRef.current.slice(0, -1)); + setFuture([...futureRef.current, current]); + setNodes(withCurrentSelection(previous.nodes, current.nodes)); + setEdges(withCurrentSelection(previous.edges, current.edges)); + onRestoreRef.current?.(previous); + }, [setNodes, setEdges]); + + const redo = useCallback(() => { + const next = futureRef.current[futureRef.current.length - 1]; + if (!next) return; + const current: WorkflowSnapshot = { + nodes: stateRef.current.nodes, + edges: stateRef.current.edges, + }; + lastGroupKeyRef.current = null; + setFuture(futureRef.current.slice(0, -1)); + setPast([...pastRef.current, current]); + setNodes(withCurrentSelection(next.nodes, current.nodes)); + setEdges(withCurrentSelection(next.edges, current.edges)); + onRestoreRef.current?.(next); + }, [setNodes, setEdges]); + + const clearHistory = useCallback(() => { + lastGroupKeyRef.current = null; + setPast([]); + setFuture([]); + }, []); + + return { + takeSnapshot, + undo, + redo, + clearHistory, + canUndo: past.length > 0, + canRedo: future.length > 0, + }; +} diff --git a/frontend/src/components/MessageInput.tsx b/frontend/src/components/MessageInput.tsx index f44797c5..eaac1eea 100644 --- a/frontend/src/components/MessageInput.tsx +++ b/frontend/src/components/MessageInput.tsx @@ -913,14 +913,13 @@ export default function MessageInput({ }); const handleInput = useCallback(() => { - if (inputRef.current) { - if (window.innerWidth < 350) inputRef.current.style.height = 'auto'; - else inputRef.current.style.height = '64px'; - inputRef.current.style.height = `${Math.min( - inputRef.current.scrollHeight, - 96, - )}px`; - } + if (!inputRef.current) return; + if (window.innerWidth < 350) inputRef.current.style.height = 'auto'; + else inputRef.current.style.height = '64px'; + inputRef.current.style.height = `${Math.min( + inputRef.current.scrollHeight, + Math.round(window.innerHeight * 0.4), + )}px`; }, []); const buildVoiceDraftValue = (baseText: string, transcript: string) => { @@ -1583,7 +1582,7 @@ export default function MessageInput({ } tabIndex={1} placeholder={t('inputPlaceholder')} - className="inputbox-style no-scrollbar dark:text-foreground dark:placeholder:text-muted-foreground/50 w-full overflow-x-hidden overflow-y-auto rounded-t-3xl bg-transparent px-2 text-base leading-tight whitespace-pre-wrap opacity-100 placeholder:text-gray-500 focus:outline-hidden sm:px-3" + className="inputbox-style dark:text-foreground dark:placeholder:text-muted-foreground/50 w-full scrollbar-thin overflow-x-hidden overflow-y-auto rounded-t-3xl bg-transparent px-2 text-base leading-tight whitespace-pre-wrap opacity-100 placeholder:text-gray-500 focus:outline-hidden sm:px-3" onInput={handleInput} onKeyDown={handleKeyDown} onPaste={handlePaste} diff --git a/frontend/src/components/MultiSelectPopover.tsx b/frontend/src/components/MultiSelectPopover.tsx index 17262cc6..fc711c50 100644 --- a/frontend/src/components/MultiSelectPopover.tsx +++ b/frontend/src/components/MultiSelectPopover.tsx @@ -201,8 +201,8 @@ export function MultiSelectPopover({
) : ( -
- +
+ {renderEmptyState()} {hasGroups ? ( grouped.groupOrder.map((groupKey) => { diff --git a/frontend/src/conversation/Conversation.tsx b/frontend/src/conversation/Conversation.tsx index a00da721..5f66fbdb 100644 --- a/frontend/src/conversation/Conversation.tsx +++ b/frontend/src/conversation/Conversation.tsx @@ -354,14 +354,20 @@ export default function Conversation() { ) : undefined } /> -
+
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index ae507f82..a46077e5 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -22,6 +22,12 @@ export default defineConfig(({ mode }) => { .map((h) => h.trim()) .filter(Boolean) : [], + // Use polling for file watching when running inside Docker. + // Native fs events do not propagate from Windows hosts into Linux + // containers, so Chokidar falls back to polling which works reliably. + watch: env.DOCKER + ? { usePolling: true, interval: 300 } + : undefined, }, test: { environment: 'happy-dom', diff --git a/tests/api/test_events_routes.py b/tests/api/test_events_routes.py index 629acd39..8eef96c3 100644 --- a/tests/api/test_events_routes.py +++ b/tests/api/test_events_routes.py @@ -450,11 +450,10 @@ class TestReplayRateLimit: mock_settings.EVENTS_REPLAY_BUDGET_WINDOW_SECONDS = 60 assert _allow_replay(None, "alice", "1735682400000-0") is True - def test_allow_replay_skips_incr_when_no_cursor_and_empty_backlog(self): - """Fresh client with no cursor and an empty user stream cannot - do snapshot work — INCR'ing the counter would needlessly - burn budget. Catches the React-StrictMode dev-burst case where - double-mounted components would otherwise 429 in 5 connects. + def test_allow_replay_never_consumes_budget_without_cursor(self): + """No cursor ⇒ no replay work ⇒ no budget. Fresh sessions start + live and must never 429 on the replay budget, no matter how many + tabs open at once (30 fresh connects/min used to exhaust it). """ from application.api.events.routes import _allow_replay @@ -462,31 +461,13 @@ class TestReplayRateLimit: mock_settings.EVENTS_REPLAY_BUDGET_REQUESTS_PER_WINDOW = 3 mock_settings.EVENTS_REPLAY_BUDGET_WINDOW_SECONDS = 60 redis = MagicMock() - redis.xlen.return_value = 0 + redis.xlen.return_value = 42 - # 5 connects in a row, all with no cursor — none consume - # budget because the backlog is empty. - for _ in range(5): + for _ in range(50): assert _allow_replay(redis, "alice", None) is True - redis.xlen.assert_called() redis.incr.assert_not_called() - - def test_allow_replay_incrs_when_no_cursor_but_backlog_present(self): - """A no-cursor connect against a non-empty backlog *will* do - snapshot work, so it consumes budget normally. - """ - from application.api.events.routes import _allow_replay - - with patch("application.api.events.routes.settings") as mock_settings: - mock_settings.EVENTS_REPLAY_BUDGET_REQUESTS_PER_WINDOW = 5 - mock_settings.EVENTS_REPLAY_BUDGET_WINDOW_SECONDS = 60 - redis = MagicMock() - redis.xlen.return_value = 42 - redis.incr.return_value = 1 - - assert _allow_replay(redis, "alice", None) is True - redis.incr.assert_called_once() + redis.xlen.assert_not_called() def test_allow_replay_passes_until_budget_exhausted(self): from application.api.events.routes import _allow_replay @@ -647,3 +628,119 @@ class TestFormatHelpers: from application.api.events.routes import _normalize_last_event_id assert _normalize_last_event_id(candidate) == expected + + +# ── replay policy: fresh sessions start live, snapshots are age-capped ── + + +class TestReplayPolicy: + def test_replay_floor_id_disabled_when_setting_zero(self): + from application.api.events.routes import _replay_floor_id + + with patch("application.api.events.routes.settings") as mock_settings: + mock_settings.EVENTS_REPLAY_MAX_AGE_HOURS = 0 + assert _replay_floor_id() is None + + def test_replay_floor_id_is_ms_stream_id(self): + from application.api.events.routes import _replay_floor_id + + with patch("application.api.events.routes.settings") as mock_settings: + mock_settings.EVENTS_REPLAY_MAX_AGE_HOURS = 48 + with patch( + "application.api.events.routes.time.time", + return_value=1_800_000_000.0, + ): + floor = _replay_floor_id() + assert floor == f"{(1_800_000_000 - 48 * 3600) * 1000}-0" + + def test_replay_backlog_uses_cursor_when_newer_than_floor(self): + from application.api.events.routes import _replay_backlog + + redis = MagicMock() + redis.xrange.return_value = [] + with patch( + "application.api.events.routes._replay_floor_id", + return_value="1000-0", + ): + list(_replay_backlog(redis, "alice", "2000-0", 200)) + assert redis.xrange.call_args.kwargs["min"] == "(2000-0" + + def test_replay_backlog_clamps_start_to_age_floor(self): + from application.api.events.routes import _replay_backlog + + redis = MagicMock() + redis.xrange.return_value = [] + with patch( + "application.api.events.routes._replay_floor_id", + return_value="5000-0", + ): + list(_replay_backlog(redis, "alice", "2000-0", 200)) + # Cursor is older than the floor: replay starts at the floor + # (inclusive), not the cursor — entries past their shelf life + # are never shipped. + assert redis.xrange.call_args.kwargs["min"] == "5000-0" + + def test_no_cursor_connect_never_replays(self): + """A fresh session (no Last-Event-ID) starts live: no XRANGE, no + weeks-old backlog on every tab-open.""" + from application.api.events import routes as events_module + + app = _make_app() + redis_client = MagicMock() + redis_client.incr.return_value = 1 + + def _fake_subscribe(self, on_subscribe=None, poll_timeout=1.0): + if on_subscribe is not None: + on_subscribe() + yield None + + with patch.object( + events_module, "get_redis_instance", return_value=redis_client + ), patch.object( + events_module.Topic, "subscribe", _fake_subscribe, create=False + ): + with app.test_client() as c: + r = c.get("/api/events", headers={"X-Test-Sub": "alice"}) + body = _drain_until( + r, lambda b: b": connected" in b, max_chunks=40 + ) + assert b": connected" in body + redis_client.xrange.assert_not_called() + + def test_cursor_older_than_floor_emits_truncation_notice(self): + """An age-clamped snapshot has a gap the client can't see from + entry ids alone — it must get the truncation notice so it + refetches full state instead of trusting a partial replay.""" + from application.api.events import routes as events_module + + app = _make_app() + redis_client = MagicMock() + redis_client.incr.return_value = 1 + # No retained-oldest available: the floor alone must trigger it. + redis_client.xinfo_stream.side_effect = Exception("nope") + redis_client.xrange.return_value = [] + + def _fake_subscribe(self, on_subscribe=None, poll_timeout=1.0): + if on_subscribe is not None: + on_subscribe() + yield None + + with patch.object( + events_module, "get_redis_instance", return_value=redis_client + ), patch.object( + events_module.Topic, "subscribe", _fake_subscribe, create=False + ), patch.object( + events_module, "_replay_floor_id", return_value="9999999999999-0" + ): + with app.test_client() as c: + r = c.get( + "/api/events", + headers={ + "X-Test-Sub": "alice", + "Last-Event-ID": "1735682400000-0", + }, + ) + body = _drain_until( + r, lambda b: b"backlog.truncated" in b, max_chunks=40 + ) + assert b"backlog.truncated" in body diff --git a/tests/test_asgi.py b/tests/test_asgi.py index 556a47a6..da516fa2 100644 --- a/tests/test_asgi.py +++ b/tests/test_asgi.py @@ -156,3 +156,15 @@ def test_cors_preflight_on_mcp_route(): assert r.status_code in (200, 204) assert r.headers.get("access-control-allow-origin") == "*" assert "Mcp-Session-Id" in r.headers.get("access-control-allow-headers", "") + + +@pytest.mark.unit +def test_wsgi_threadpool_sized_from_settings(): + """The Flask thread pool is the app's request-capacity ceiling — + it must be operator-tunable (prod incident 2026-07-05→07: 32 slots + exhausted by SSE holders starved all other requests).""" + from application import asgi + from application.core.settings import settings + + assert asgi._WSGI_THREADPOOL == int(settings.WSGI_THREADPOOL_WORKERS) + assert asgi._WSGI_THREADPOOL >= 64 diff --git a/tests/test_events_substrate.py b/tests/test_events_substrate.py index 7523f456..ef42b164 100644 --- a/tests/test_events_substrate.py +++ b/tests/test_events_substrate.py @@ -305,7 +305,7 @@ class TestTopic: # Must not raise. assert Topic("user:alice").publish("hi") == 0 - @patch("application.streaming.broadcast_channel.get_redis_instance") + @patch("application.streaming.broadcast_channel.get_pubsub_redis_instance") def test_subscribe_returns_immediately_when_redis_unavailable( self, mock_redis ): @@ -313,7 +313,7 @@ class TestTopic: # Generator should produce nothing, not raise. assert list(Topic("user:alice").subscribe(poll_timeout=0.01)) == [] - @patch("application.streaming.broadcast_channel.get_redis_instance") + @patch("application.streaming.broadcast_channel.get_pubsub_redis_instance") def test_subscribe_yields_none_on_poll_timeout(self, mock_redis): client = MagicMock() pubsub = MagicMock() @@ -331,7 +331,7 @@ class TestTopic: assert third == b"x" gen.close() - @patch("application.streaming.broadcast_channel.get_redis_instance") + @patch("application.streaming.broadcast_channel.get_pubsub_redis_instance") def test_subscribe_fires_on_subscribe_after_ack(self, mock_redis): client = MagicMock() pubsub = MagicMock() @@ -355,7 +355,7 @@ class TestTopic: assert callback_calls == [1] gen.close() - @patch("application.streaming.broadcast_channel.get_redis_instance") + @patch("application.streaming.broadcast_channel.get_pubsub_redis_instance") def test_subscribe_cleans_up_on_generator_close(self, mock_redis): client = MagicMock() pubsub = MagicMock() @@ -373,7 +373,7 @@ class TestTopic: pubsub.unsubscribe.assert_called_once_with("user:alice") pubsub.close.assert_called_once() - @patch("application.streaming.broadcast_channel.get_redis_instance") + @patch("application.streaming.broadcast_channel.get_pubsub_redis_instance") def test_subscribe_skips_unsubscribe_if_subscribe_never_acked( self, mock_redis ): diff --git a/tests/test_pubsub_timeouts.py b/tests/test_pubsub_timeouts.py new file mode 100644 index 00000000..13b28a1c --- /dev/null +++ b/tests/test_pubsub_timeouts.py @@ -0,0 +1,116 @@ +"""Tests for the pub/sub-dedicated Redis client and subscriber timeout handling. + +Regression tests for the 2026-07-05→07 prod incident: idle pub/sub +connections silently dropped by NAT/IPVS never raised inside +``pubsub.get_message`` (the shared cache client has no ``socket_timeout``), +leaving each ``/api/events`` SSE generator blocked in ``_read_from_socket`` +forever. One WSGI thread-pool slot leaked per dead subscriber until the +pool was exhausted and the worker stopped serving Flask requests. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +import redis as redis_lib + +import application.cache as cache +from application.streaming.broadcast_channel import Topic + + +@pytest.fixture(autouse=True) +def _reset_pubsub_singleton(): + cache._pubsub_redis_instance = None + cache._pubsub_redis_creation_failed = False + yield + cache._pubsub_redis_instance = None + cache._pubsub_redis_creation_failed = False + + +class TestGetPubsubRedisInstance: + + @pytest.mark.unit + def test_bounds_blocking_reads_and_enables_keepalive(self): + with patch("application.cache.redis.Redis.from_url") as from_url: + client = cache.get_pubsub_redis_instance() + assert client is from_url.return_value + kwargs = from_url.call_args.kwargs + assert kwargs["socket_timeout"] == cache.PUBSUB_SOCKET_TIMEOUT_SECONDS + assert kwargs["socket_timeout"] > 0 + assert kwargs["socket_keepalive"] is True + assert kwargs["health_check_interval"] == 10 + + @pytest.mark.unit + def test_socket_timeout_exceeds_subscribe_poll_interval(self): + """get_message(timeout=poll) waits ``poll`` seconds while healthy-idle; + socket_timeout must be comfortably larger or idle polling would be + misread as a dead connection.""" + assert cache.PUBSUB_SOCKET_TIMEOUT_SECONDS >= 5 + + @pytest.mark.unit + def test_singleton(self): + with patch("application.cache.redis.Redis.from_url") as from_url: + first = cache.get_pubsub_redis_instance() + second = cache.get_pubsub_redis_instance() + assert first is second + assert from_url.call_count == 1 + + @pytest.mark.unit + def test_invalid_url_marks_failed_and_stops_retrying(self): + with patch( + "application.cache.redis.Redis.from_url", side_effect=ValueError("bad url") + ): + assert cache.get_pubsub_redis_instance() is None + with patch("application.cache.redis.Redis.from_url") as from_url: + assert cache.get_pubsub_redis_instance() is None + from_url.assert_not_called() + + @pytest.mark.unit + def test_connection_error_allows_retry(self): + with patch( + "application.cache.redis.Redis.from_url", + side_effect=redis_lib.ConnectionError("down"), + ): + assert cache.get_pubsub_redis_instance() is None + with patch("application.cache.redis.Redis.from_url") as from_url: + assert cache.get_pubsub_redis_instance() is from_url.return_value + + +class TestSubscribeTimeout: + + @pytest.mark.unit + def test_socket_timeout_ends_subscription(self): + """A read timeout means the connection is half-open/dead: the generator + must exit — freeing its WSGI thread — rather than loop or raise.""" + pubsub = MagicMock() + pubsub.get_message.side_effect = redis_lib.exceptions.TimeoutError( + "Timeout reading from socket" + ) + client = MagicMock() + client.pubsub.return_value = pubsub + with patch( + "application.streaming.broadcast_channel.get_pubsub_redis_instance", + return_value=client, + ): + events = list(Topic("user:u1").subscribe()) + assert events == [] + pubsub.close.assert_called_once() + + @pytest.mark.unit + def test_subscribe_uses_dedicated_pubsub_client(self): + """The shared cache client has no socket_timeout; subscribers must not + fall back to it.""" + pubsub = MagicMock() + pubsub.get_message.side_effect = redis_lib.exceptions.TimeoutError() + client = MagicMock() + client.pubsub.return_value = pubsub + with patch( + "application.streaming.broadcast_channel.get_pubsub_redis_instance", + return_value=client, + ) as get_pubsub, patch( + "application.streaming.broadcast_channel.get_redis_instance" + ) as get_cache: + list(Topic("user:u1").subscribe()) + get_pubsub.assert_called_once() + get_cache.assert_not_called()