Compare commits

...

22 Commits

Author SHA1 Message Date
Alex
ddd5704c49 fix: mini docstring stuff 2026-04-26 00:56:31 +01:00
Alex
d54e6d8b34 fix: test 2026-04-26 00:36:35 +01:00
Alex
2806825959 feat: simplified model structure 2026-04-26 00:20:37 +01:00
Alex
ef976eeb06 feat: make version check periodic 2026-04-25 14:57:37 +01:00
Alex
9c8ae9d540 feat: redbeat 2026-04-25 14:38:24 +01:00
Alex
7ca33b2b72 feat: OTEL 2026-04-25 13:38:03 +01:00
Manish Madan
d1b9798f62 Merge pull request #2422 from arc53/dependabot/npm_and_yarn/extensions/react-widget/babel/plugin-transform-flow-strip-types-7.27.1
chore(deps): bump @babel/plugin-transform-flow-strip-types from 7.24.6 to 7.27.1 in /extensions/react-widget
2026-04-24 05:13:00 +05:30
dependabot[bot]
ddc3adf3ab chore(deps): bump @babel/plugin-transform-flow-strip-types
Bumps [@babel/plugin-transform-flow-strip-types](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-flow-strip-types) from 7.24.6 to 7.27.1.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.1/packages/babel-plugin-transform-flow-strip-types)

---
updated-dependencies:
- dependency-name: "@babel/plugin-transform-flow-strip-types"
  dependency-version: 7.27.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-23 23:41:46 +00:00
Manish Madan
a4991d01ac Merge pull request #2421 from arc53/dependabot/npm_and_yarn/extensions/react-widget/typescript-6.0.3
chore(deps-dev): bump typescript from 5.9.3 to 6.0.3 in /extensions/react-widget
2026-04-24 05:09:58 +05:30
ManishMadan2882
87fd1bd359 chore(deps-dev): bump @typescript-eslint/{eslint-plugin,parser} to ^8.58.0 for TS 6 support 2026-04-24 05:08:17 +05:30
dependabot[bot]
c71e986d34 chore(deps-dev): bump typescript in /extensions/react-widget
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 6.0.3.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.3)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 6.0.3
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-23 23:18:29 +00:00
Manish Madan
a2a06c569e Merge pull request #2419 from arc53/dependabot/npm_and_yarn/extensions/react-widget/prettier-3.8.3
chore(deps-dev): bump prettier from 3.8.1 to 3.8.3 in /extensions/react-widget
2026-04-24 04:34:37 +05:30
Alex
c5f00a1d1b fix: remove old extension in external repo's 2026-04-23 23:27:38 +01:00
Alex
2a15bb0102 chore: delete old extensions 2026-04-23 22:55:03 +01:00
Alex
c06888bc86 feat: asgi and search service (#2424)
* feat: asgi and search service

* feat: asgi and mcp tool server

* fix: asgi issues

* fix: mini cors hardening
2026-04-23 12:21:39 +01:00
Alex
d4b1c1fd81 chore: 0.17.0 version 2026-04-21 16:16:11 +01:00
Alex
2de84acf81 fix: mini callout 2026-04-21 16:14:08 +01:00
Alex
2702750861 docs: upgrading guide 2026-04-21 15:04:17 +01:00
Alex
2b5f20d0ec fix: safer version 2026-04-21 14:22:32 +01:00
Alex
619b41dc5b fix: better version fetch 2026-04-21 14:07:26 +01:00
Alex
76d8f49ccb feat: security version check 2026-04-21 09:16:52 +01:00
dependabot[bot]
65460b0c03 chore(deps-dev): bump prettier in /extensions/react-widget
Bumps [prettier](https://github.com/prettier/prettier) from 3.8.1 to 3.8.3.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.8.1...3.8.3)

---
updated-dependencies:
- dependency-name: prettier
  dependency-version: 3.8.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-20 22:35:52 +00:00
126 changed files with 5050 additions and 15368 deletions

View File

@@ -35,8 +35,5 @@ MICROSOFT_TENANT_ID=your-azure-ad-tenant-id
#Alternatively, use "https://login.microsoftonline.com/common" for multi-tenant app.
MICROSOFT_AUTHORITY=https://{tenantId}.ciamlogin.com/{tenantId}
# User-data Postgres DB (Phase 0 of the MongoDB→Postgres migration).
# Standard Postgres URI — `postgres://` and `postgresql://` both work.
# Leave unset while the migration is still being rolled out; the app will
# fall back to MongoDB for user data until POSTGRES_URI is configured.
# POSTGRES_URI=postgresql://docsgpt:docsgpt@localhost:5432/docsgpt

View File

@@ -37,6 +37,22 @@ Run the Flask API (if needed):
flask --app application/app.py run --host=0.0.0.0 --port=7091
```
That's the fast inner-loop option — quick startup, the Werkzeug interactive
debugger still works, and it hot-reloads on source changes. It serves the
Flask routes only (`/api/*`, `/stream`, etc.).
If you need to exercise the full ASGI stack — the `/mcp` FastMCP endpoint,
or to match the production runtime exactly — run the ASGI composition under
uvicorn instead:
```bash
uvicorn application.asgi:asgi_app --host 0.0.0.0 --port 7091 --reload
```
Production uses `gunicorn -k uvicorn_worker.UvicornWorker` against the same
`application.asgi:asgi_app` target; see `application/Dockerfile` for the
full flag set.
Run the Celery worker in a separate terminal (if needed):
```bash
@@ -99,7 +115,7 @@ vale .
- `frontend/`: Vite + React + TypeScript application.
- `frontend/src/`: main UI code, including `components`, `conversation`, `hooks`, `locale`, `settings`, `upload`, and Redux store wiring in `store.ts`.
- `docs/`: separate documentation site built with Next.js/Nextra.
- `extensions/`: integrations and widgets such as Chatwoot, Chrome, Discord, React widget, Slack bot, and web widget.
- `extensions/`: integrations and widgets — currently the Chatwoot webhook bridge and the React widget (published to npm as `docsgpt`). The Discord bot, Slack bot, and Chrome extension have been moved to their own repos under `arc53/`.
- `deployment/`: Docker Compose variants and Kubernetes manifests.
## Coding rules

View File

@@ -88,5 +88,15 @@ EXPOSE 7091
# Switch to non-root user
USER appuser
# Start Gunicorn
CMD ["gunicorn", "-w", "1", "--timeout", "120", "--bind", "0.0.0.0:7091", "--preload", "application.wsgi:app"]
CMD ["gunicorn", \
"-w", "1", \
"-k", "uvicorn_worker.UvicornWorker", \
"--bind", "0.0.0.0:7091", \
"--timeout", "180", \
"--graceful-timeout", "120", \
"--keep-alive", "5", \
"--worker-tmp-dir", "/dev/shm", \
"--max-requests", "1000", \
"--max-requests-jitter", "100", \
"--config", "application/gunicorn_conf.py", \
"application.asgi:asgi_app"]

View File

@@ -0,0 +1,37 @@
"""0002 app_metadata — singleton key/value table for instance-wide state.
Used by the startup version-check client to persist the anonymous
instance UUID and a one-shot "notice shown" flag. Both values are tiny
plain-text strings; this is a deliberate generic-config table rather
than dedicated columns so future one-off settings (telemetry opt-in
timestamps, feature-flag overrides, etc.) don't each need their own
migration.
Revision ID: 0002_app_metadata
Revises: 0001_initial
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0002_app_metadata"
down_revision: Union[str, None] = "0001_initial"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(
"""
CREATE TABLE app_metadata (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
"""
)
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS app_metadata;")

View File

@@ -1,21 +1,21 @@
import logging
from typing import Any, Dict, List
from flask import make_response, request
from flask_restx import fields, Resource
from application.api.answer.routes.base import answer_ns
from application.core.settings import settings
from application.storage.db.repositories.agents import AgentsRepository
from application.storage.db.session import db_readonly
from application.vectorstore.vector_creator import VectorCreator
from application.services.search_service import (
InvalidAPIKey,
SearchFailed,
search,
)
logger = logging.getLogger(__name__)
@answer_ns.route("/api/search")
class SearchResource(Resource):
"""Fast search endpoint for retrieving relevant documents"""
"""Fast search endpoint for retrieving relevant documents."""
search_model = answer_ns.model(
"SearchModel",
@@ -32,102 +32,10 @@ class SearchResource(Resource):
},
)
def _get_sources_from_api_key(self, api_key: str) -> List[str]:
"""Get source IDs connected to the API key/agent."""
with db_readonly() as conn:
agent_data = AgentsRepository(conn).find_by_key(api_key)
if not agent_data:
return []
source_ids: List[str] = []
# extra_source_ids is a PG ARRAY(UUID) of source UUIDs.
extra = agent_data.get("extra_source_ids") or []
for src in extra:
if src:
source_ids.append(str(src))
if not source_ids:
single = agent_data.get("source_id")
if single:
source_ids.append(str(single))
return source_ids
def _search_vectorstores(
self, query: str, source_ids: List[str], chunks: int
) -> List[Dict[str, Any]]:
"""Search across vectorstores and return results"""
if not source_ids:
return []
results = []
chunks_per_source = max(1, chunks // len(source_ids))
seen_texts = set()
for source_id in source_ids:
if not source_id or not source_id.strip():
continue
try:
docsearch = VectorCreator.create_vectorstore(
settings.VECTOR_STORE, source_id, settings.EMBEDDINGS_KEY
)
docs = docsearch.search(query, k=chunks_per_source * 2)
for doc in docs:
if len(results) >= chunks:
break
if hasattr(doc, "page_content") and hasattr(doc, "metadata"):
page_content = doc.page_content
metadata = doc.metadata
else:
page_content = doc.get("text", doc.get("page_content", ""))
metadata = doc.get("metadata", {})
# Skip duplicates
text_hash = hash(page_content[:200])
if text_hash in seen_texts:
continue
seen_texts.add(text_hash)
title = metadata.get(
"title", metadata.get("post_title", "")
)
if not isinstance(title, str):
title = str(title) if title else ""
# Clean up title
if title:
title = title.split("/")[-1]
else:
# Use filename or first part of content as title
title = metadata.get("filename", page_content[:50] + "...")
source = metadata.get("source", source_id)
results.append({
"text": page_content,
"title": title,
"source": source,
})
if len(results) >= chunks:
break
except Exception as e:
logger.error(
f"Error searching vectorstore {source_id}: {e}",
exc_info=True,
)
continue
return results[:chunks]
@answer_ns.expect(search_model)
@answer_ns.doc(description="Search for relevant documents based on query")
def post(self):
data = request.get_json()
data = request.get_json() or {}
question = data.get("question")
api_key = data.get("api_key")
@@ -135,32 +43,13 @@ class SearchResource(Resource):
if not question:
return make_response({"error": "question is required"}, 400)
if not api_key:
return make_response({"error": "api_key is required"}, 400)
# Validate API key
with db_readonly() as conn:
agent = AgentsRepository(conn).find_by_key(api_key)
if not agent:
return make_response({"error": "Invalid API key"}, 401)
try:
# Get sources connected to this API key
source_ids = self._get_sources_from_api_key(api_key)
if not source_ids:
return make_response([], 200)
# Perform search
results = self._search_vectorstores(question, source_ids, chunks)
return make_response(results, 200)
except Exception as e:
logger.error(
f"/api/search - error: {str(e)}",
extra={"error": str(e)},
exc_info=True,
)
return make_response(search(api_key, question, chunks), 200)
except InvalidAPIKey:
return make_response({"error": "Invalid API key"}, 401)
except SearchFailed:
logger.exception("/api/search failed")
return make_response({"error": "Search failed"}, 500)

View File

@@ -140,6 +140,11 @@ def setup_periodic_tasks(sender, **kwargs):
cleanup_pending_tool_state.s(),
name="cleanup-pending-tool-state",
)
sender.add_periodic_task(
timedelta(hours=7),
version_check_task.s(),
name="version-check",
)
@celery.task(bind=True)
@@ -176,3 +181,16 @@ def cleanup_pending_tool_state(self):
with engine.begin() as conn:
deleted = PendingToolStateRepository(conn).cleanup_expired()
return {"deleted": deleted}
@celery.task(bind=True)
def version_check_task(self):
"""Periodic anonymous version check.
Complements the ``worker_ready`` boot trigger so long-running
deployments (>6h cache TTL) still refresh advisories. ``run_check``
is fail-silent and coordinates across replicas via Redis lock +
cache (see ``application.updates.version_check``).
"""
from application.updates.version_check import run_check
run_check()

View File

@@ -4,7 +4,7 @@ import platform
import uuid
import dotenv
from flask import Flask, jsonify, redirect, request
from flask import Flask, Response, jsonify, redirect, request
from jose import jwt
from application.auth import handle_auth
@@ -149,12 +149,11 @@ def authenticate_request():
@app.after_request
def after_request(response):
response.headers.add("Access-Control-Allow-Origin", "*")
response.headers.add("Access-Control-Allow-Headers", "Content-Type, Authorization")
response.headers.add(
"Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"
)
def after_request(response: Response) -> Response:
"""Add CORS headers for the pure Flask development entrypoint."""
response.headers["Access-Control-Allow-Origin"] = "*"
response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization"
response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
return response

33
application/asgi.py Normal file
View File

@@ -0,0 +1,33 @@
"""ASGI entrypoint: Flask (WSGI) + FastMCP on the same process."""
from __future__ import annotations
from a2wsgi import WSGIMiddleware
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.cors import CORSMiddleware
from starlette.routing import Mount
from application.app import app as flask_app
from application.mcp_server import mcp
_WSGI_THREADPOOL = 32
mcp_app = mcp.http_app(path="/")
asgi_app = Starlette(
routes=[
Mount("/mcp", app=mcp_app),
Mount("/", app=WSGIMiddleware(flask_app, workers=_WSGI_THREADPOOL)),
],
middleware=[
Middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "Authorization", "Mcp-Session-Id"],
expose_headers=["Mcp-Session-Id"],
),
],
lifespan=mcp_app.lifespan,
)

View File

@@ -1,6 +1,8 @@
import threading
from celery import Celery
from application.core.settings import settings
from celery.signals import setup_logging, worker_process_init
from celery.signals import setup_logging, worker_process_init, worker_ready
def make_celery(app_name=__name__):
@@ -39,5 +41,25 @@ def _dispose_db_engine_on_fork(*args, **kwargs):
dispose_engine()
@worker_ready.connect
def _run_version_check(*args, **kwargs):
"""Kick off the anonymous version check on worker startup.
Runs in a daemon thread so a slow endpoint or bad DNS never holds
up the worker becoming ready for tasks. The check itself is
fail-silent (see ``application.updates.version_check.run_check``);
this handler's only job is to launch it and get out of the way.
Import is lazy so the symbol resolution never fires at module
import time — consistent with the ``_dispose_db_engine_on_fork``
pattern above.
"""
try:
from application.updates.version_check import run_check
except Exception:
return
threading.Thread(target=run_check, name="version-check", daemon=True).start()
celery = make_celery()
celery.config_from_object("application.celeryconfig")

View File

@@ -9,3 +9,8 @@ accept_content = ['json']
# Autodiscover tasks
imports = ('application.api.user.tasks',)
beat_scheduler = "redbeat.RedBeatScheduler"
redbeat_redis_url = broker_url
redbeat_key_prefix = "redbeat:docsgpt:"
redbeat_lock_timeout = 90

View File

@@ -1,11 +1,45 @@
import logging
import os
from logging.config import dictConfig
def setup_logging():
def _otlp_logs_enabled() -> bool:
"""Return True when the user has opted in to OTLP log export.
Gated by the standard OTEL env vars so no project-specific knob is needed:
set ``OTEL_LOGS_EXPORTER=otlp`` (and leave ``OTEL_SDK_DISABLED`` unset or
false) to flip it on. When false, ``setup_logging`` keeps its original
console-only behavior.
"""
exporter = os.getenv("OTEL_LOGS_EXPORTER", "").strip().lower()
disabled = os.getenv("OTEL_SDK_DISABLED", "false").strip().lower() == "true"
return exporter == "otlp" and not disabled
def setup_logging() -> None:
"""Configure the root logger with a stdout console handler.
When OTLP log export is enabled, ``opentelemetry-instrument`` attaches a
``LoggingHandler`` to the root logger before this function runs. The
``dictConfig`` call below replaces ``root.handlers`` with the console
handler, which would silently drop the OTEL handler. To make OTLP log
export work without forcing every contributor to opt in, snapshot the
OTEL handlers up front and re-attach them after ``dictConfig``.
"""
preserved_handlers: list[logging.Handler] = []
if _otlp_logs_enabled():
preserved_handlers = [
h
for h in logging.getLogger().handlers
if h.__class__.__module__.startswith("opentelemetry")
]
dictConfig({
'version': 1,
'formatters': {
'default': {
'format': '[%(asctime)s] %(levelname)s in %(module)s: %(message)s',
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"default": {
"format": "[%(asctime)s] %(levelname)s in %(module)s: %(message)s",
}
},
"handlers": {
@@ -15,8 +49,14 @@ def setup_logging():
"formatter": "default",
}
},
'root': {
'level': 'INFO',
'handlers': ['console'],
"root": {
"level": "INFO",
"handlers": ["console"],
},
})
})
if preserved_handlers:
root = logging.getLogger()
for handler in preserved_handlers:
if handler not in root.handlers:
root.addHandler(handler)

View File

@@ -1,266 +0,0 @@
"""
Model configurations for all supported LLM providers.
"""
from application.core.model_settings import (
AvailableModel,
ModelCapabilities,
ModelProvider,
)
# Base image attachment types supported by most vision-capable LLMs
IMAGE_ATTACHMENTS = [
"image/png",
"image/jpeg",
"image/jpg",
"image/webp",
"image/gif",
]
# PDF excluded: most OpenAI-compatible endpoints don't support native PDF uploads.
# When excluded, PDFs are synthetically processed by converting pages to images.
OPENAI_ATTACHMENTS = IMAGE_ATTACHMENTS
GOOGLE_ATTACHMENTS = ["application/pdf"] + IMAGE_ATTACHMENTS
ANTHROPIC_ATTACHMENTS = IMAGE_ATTACHMENTS
OPENROUTER_ATTACHMENTS = IMAGE_ATTACHMENTS
NOVITA_ATTACHMENTS = IMAGE_ATTACHMENTS
OPENAI_MODELS = [
AvailableModel(
id="gpt-5.1",
provider=ModelProvider.OPENAI,
display_name="GPT-5.1",
description="Flagship model with enhanced reasoning, coding, and agentic capabilities",
capabilities=ModelCapabilities(
supports_tools=True,
supports_structured_output=True,
supported_attachment_types=OPENAI_ATTACHMENTS,
context_window=200000,
),
),
AvailableModel(
id="gpt-5-mini",
provider=ModelProvider.OPENAI,
display_name="GPT-5 Mini",
description="Faster, cost-effective variant of GPT-5.1",
capabilities=ModelCapabilities(
supports_tools=True,
supports_structured_output=True,
supported_attachment_types=OPENAI_ATTACHMENTS,
context_window=200000,
),
)
]
ANTHROPIC_MODELS = [
AvailableModel(
id="claude-3-5-sonnet-20241022",
provider=ModelProvider.ANTHROPIC,
display_name="Claude 3.5 Sonnet (Latest)",
description="Latest Claude 3.5 Sonnet with enhanced capabilities",
capabilities=ModelCapabilities(
supports_tools=True,
supported_attachment_types=ANTHROPIC_ATTACHMENTS,
context_window=200000,
),
),
AvailableModel(
id="claude-3-5-sonnet",
provider=ModelProvider.ANTHROPIC,
display_name="Claude 3.5 Sonnet",
description="Balanced performance and capability",
capabilities=ModelCapabilities(
supports_tools=True,
supported_attachment_types=ANTHROPIC_ATTACHMENTS,
context_window=200000,
),
),
AvailableModel(
id="claude-3-opus",
provider=ModelProvider.ANTHROPIC,
display_name="Claude 3 Opus",
description="Most capable Claude model",
capabilities=ModelCapabilities(
supports_tools=True,
supported_attachment_types=ANTHROPIC_ATTACHMENTS,
context_window=200000,
),
),
AvailableModel(
id="claude-3-haiku",
provider=ModelProvider.ANTHROPIC,
display_name="Claude 3 Haiku",
description="Fastest Claude model",
capabilities=ModelCapabilities(
supports_tools=True,
supported_attachment_types=ANTHROPIC_ATTACHMENTS,
context_window=200000,
),
),
]
GOOGLE_MODELS = [
AvailableModel(
id="gemini-flash-latest",
provider=ModelProvider.GOOGLE,
display_name="Gemini Flash (Latest)",
description="Latest experimental Gemini model",
capabilities=ModelCapabilities(
supports_tools=True,
supports_structured_output=True,
supported_attachment_types=GOOGLE_ATTACHMENTS,
context_window=int(1e6),
),
),
AvailableModel(
id="gemini-flash-lite-latest",
provider=ModelProvider.GOOGLE,
display_name="Gemini Flash Lite (Latest)",
description="Fast with huge context window",
capabilities=ModelCapabilities(
supports_tools=True,
supports_structured_output=True,
supported_attachment_types=GOOGLE_ATTACHMENTS,
context_window=int(1e6),
),
),
AvailableModel(
id="gemini-3-pro-preview",
provider=ModelProvider.GOOGLE,
display_name="Gemini 3 Pro",
description="Most capable Gemini model",
capabilities=ModelCapabilities(
supports_tools=True,
supports_structured_output=True,
supported_attachment_types=GOOGLE_ATTACHMENTS,
context_window=2000000,
),
),
]
GROQ_MODELS = [
AvailableModel(
id="llama-3.3-70b-versatile",
provider=ModelProvider.GROQ,
display_name="Llama 3.3 70B",
description="Latest Llama model with high-speed inference",
capabilities=ModelCapabilities(
supports_tools=True,
context_window=128000,
),
),
AvailableModel(
id="openai/gpt-oss-120b",
provider=ModelProvider.GROQ,
display_name="GPT-OSS 120B",
description="Open-source GPT model optimized for speed",
capabilities=ModelCapabilities(
supports_tools=True,
context_window=128000,
),
),
]
OPENROUTER_MODELS = [
AvailableModel(
id="qwen/qwen3-coder:free",
provider=ModelProvider.OPENROUTER,
display_name="Qwen 3 Coder",
description="Latest Qwen model with high-speed inference",
capabilities=ModelCapabilities(
supports_tools=True,
context_window=128000,
supported_attachment_types=OPENROUTER_ATTACHMENTS
),
),
AvailableModel(
id="google/gemma-3-27b-it:free",
provider=ModelProvider.OPENROUTER,
display_name="Gemma 3 27B",
description="Latest Gemma model with high-speed inference",
capabilities=ModelCapabilities(
supports_tools=True,
context_window=128000,
supported_attachment_types=OPENROUTER_ATTACHMENTS
),
),
]
NOVITA_MODELS = [
AvailableModel(
id="moonshotai/kimi-k2.5",
provider=ModelProvider.NOVITA,
display_name="Kimi K2.5",
description="MoE model with function calling, structured output, reasoning, and vision",
capabilities=ModelCapabilities(
supports_tools=True,
supports_structured_output=True,
supported_attachment_types=NOVITA_ATTACHMENTS,
context_window=262144,
),
),
AvailableModel(
id="zai-org/glm-5",
provider=ModelProvider.NOVITA,
display_name="GLM-5",
description="MoE model with function calling, structured output, and reasoning",
capabilities=ModelCapabilities(
supports_tools=True,
supports_structured_output=True,
supported_attachment_types=[],
context_window=202800,
),
),
AvailableModel(
id="minimax/minimax-m2.5",
provider=ModelProvider.NOVITA,
display_name="MiniMax M2.5",
description="MoE model with function calling, structured output, and reasoning",
capabilities=ModelCapabilities(
supports_tools=True,
supports_structured_output=True,
supported_attachment_types=[],
context_window=204800,
),
),
]
AZURE_OPENAI_MODELS = [
AvailableModel(
id="azure-gpt-4",
provider=ModelProvider.AZURE_OPENAI,
display_name="Azure OpenAI GPT-4",
description="Azure-hosted GPT model",
capabilities=ModelCapabilities(
supports_tools=True,
supports_structured_output=True,
supported_attachment_types=OPENAI_ATTACHMENTS,
context_window=8192,
),
),
]
def create_custom_openai_model(model_name: str, base_url: str) -> AvailableModel:
"""Create a custom OpenAI-compatible model (e.g., LM Studio, Ollama)."""
return AvailableModel(
id=model_name,
provider=ModelProvider.OPENAI,
display_name=model_name,
description=f"Custom OpenAI-compatible model at {base_url}",
base_url=base_url,
capabilities=ModelCapabilities(
supports_tools=True,
supported_attachment_types=OPENAI_ATTACHMENTS,
),
)

View File

@@ -0,0 +1,164 @@
"""Layered model registry.
Loads model catalogs from YAML files (built-in + operator-supplied),
groups them by provider name, then for each registered provider plugin
calls ``get_models`` to produce the final per-provider model list.
The ``user_id`` parameter on lookup methods is reserved for the future
end-user BYOM (per-user model records in Postgres). It is currently
ignored — defaulted to ``None`` everywhere — so call sites can be
threaded through without a wide refactor when BYOM lands.
"""
from __future__ import annotations
import logging
from collections import defaultdict
from typing import Dict, List, Optional
from application.core.model_settings import AvailableModel
from application.core.model_yaml import (
BUILTIN_MODELS_DIR,
ProviderCatalog,
load_model_yamls,
)
logger = logging.getLogger(__name__)
class ModelRegistry:
"""Singleton registry of available models."""
_instance: Optional["ModelRegistry"] = None
_initialized: bool = False
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
if not ModelRegistry._initialized:
self.models: Dict[str, AvailableModel] = {}
self.default_model_id: Optional[str] = None
self._load_models()
ModelRegistry._initialized = True
@classmethod
def get_instance(cls) -> "ModelRegistry":
return cls()
@classmethod
def reset(cls) -> None:
"""Clear the singleton. Intended for test fixtures."""
cls._instance = None
cls._initialized = False
def _load_models(self) -> None:
from pathlib import Path
from application.core.settings import settings
from application.llm.providers import ALL_PROVIDERS
directories = [BUILTIN_MODELS_DIR]
operator_dir = getattr(settings, "MODELS_CONFIG_DIR", None)
if operator_dir:
op_path = Path(operator_dir)
if not op_path.exists():
logger.warning(
"MODELS_CONFIG_DIR=%s does not exist; no operator "
"model YAMLs will be loaded.",
operator_dir,
)
elif not op_path.is_dir():
logger.warning(
"MODELS_CONFIG_DIR=%s is not a directory; no operator "
"model YAMLs will be loaded.",
operator_dir,
)
else:
directories.append(op_path)
catalogs = load_model_yamls(directories)
# Validate every catalog targets a known plugin before doing any
# registry work, so an unknown provider name in YAML aborts boot
# with a clear error.
plugin_names = {p.name for p in ALL_PROVIDERS}
for c in catalogs:
if c.provider not in plugin_names:
raise ValueError(
f"{c.source_path}: YAML declares unknown provider "
f"{c.provider!r}; no Provider plugin is registered "
f"under that name. Known: {sorted(plugin_names)}"
)
catalogs_by_provider: Dict[str, List[ProviderCatalog]] = defaultdict(list)
for c in catalogs:
catalogs_by_provider[c.provider].append(c)
self.models.clear()
for provider in ALL_PROVIDERS:
if not provider.is_enabled(settings):
continue
for model in provider.get_models(
settings, catalogs_by_provider.get(provider.name, [])
):
self.models[model.id] = model
self.default_model_id = self._resolve_default(settings)
logger.info(
"ModelRegistry loaded %d models, default: %s",
len(self.models),
self.default_model_id,
)
def _resolve_default(self, settings) -> Optional[str]:
if settings.LLM_NAME:
for name in self._parse_model_names(settings.LLM_NAME):
if name in self.models:
return name
if settings.LLM_NAME in self.models:
return settings.LLM_NAME
if settings.LLM_PROVIDER and settings.API_KEY:
for model_id, model in self.models.items():
if model.provider.value == settings.LLM_PROVIDER:
return model_id
if self.models:
return next(iter(self.models.keys()))
return None
@staticmethod
def _parse_model_names(llm_name: str) -> List[str]:
if not llm_name:
return []
return [name.strip() for name in llm_name.split(",") if name.strip()]
# ------------------------------------------------------------------
# Lookup API. ``user_id`` is reserved for the future BYOM and
# is ignored today — but threading it through every call site now
# means BYOM doesn't require a wide refactor when we build it.
# ------------------------------------------------------------------
def get_model(
self, model_id: str, user_id: Optional[str] = None
) -> Optional[AvailableModel]:
return self.models.get(model_id)
def get_all_models(
self, user_id: Optional[str] = None
) -> List[AvailableModel]:
return list(self.models.values())
def get_enabled_models(
self, user_id: Optional[str] = None
) -> List[AvailableModel]:
return [m for m in self.models.values() if m.enabled]
def model_exists(
self, model_id: str, user_id: Optional[str] = None
) -> bool:
return model_id in self.models

View File

@@ -5,9 +5,16 @@ from typing import Dict, List, Optional
logger = logging.getLogger(__name__)
# Re-exported here so existing call sites (and tests) that do
# ``from application.core.model_settings import ModelRegistry`` keep
# working. The implementation lives in ``application/core/model_registry.py``.
# Imported lazily inside ``__getattr__`` to avoid an import cycle with
# ``model_yaml`` → ``model_settings`` (this file).
class ModelProvider(str, Enum):
OPENAI = "openai"
OPENAI_COMPATIBLE = "openai_compatible"
OPENROUTER = "openrouter"
AZURE_OPENAI = "azure_openai"
ANTHROPIC = "anthropic"
@@ -41,11 +48,20 @@ class AvailableModel:
capabilities: ModelCapabilities = field(default_factory=ModelCapabilities)
enabled: bool = True
base_url: Optional[str] = None
# User-facing label distinct from the dispatch ``provider``. Used by
# openai_compatible YAMLs so a Mistral model shows "mistral" in the
# API response while still routing through the OpenAI wire format.
display_provider: Optional[str] = None
# Per-record API key. Operator YAMLs leave this None; populated for
# openai_compatible models (resolved from the YAML's ``api_key_env``)
# and reserved for the future end-user BYOM phase. Never serialized
# into to_dict().
api_key: Optional[str] = field(default=None, repr=False, compare=False)
def to_dict(self) -> Dict:
result = {
"id": self.id,
"provider": self.provider.value,
"provider": self.display_provider or self.provider.value,
"display_name": self.display_name,
"description": self.description,
"supported_attachment_types": self.capabilities.supported_attachment_types,
@@ -60,255 +76,14 @@ class AvailableModel:
return result
class ModelRegistry:
_instance = None
_initialized = False
def __getattr__(name):
"""Lazy re-export of ``ModelRegistry`` from ``model_registry.py``.
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
Done lazily to avoid an import cycle: ``model_registry`` imports
``model_yaml`` which imports the dataclasses from this file.
"""
if name == "ModelRegistry":
from application.core.model_registry import ModelRegistry as _MR
def __init__(self):
if not ModelRegistry._initialized:
self.models: Dict[str, AvailableModel] = {}
self.default_model_id: Optional[str] = None
self._load_models()
ModelRegistry._initialized = True
@classmethod
def get_instance(cls) -> "ModelRegistry":
return cls()
def _load_models(self):
from application.core.settings import settings
self.models.clear()
# Skip DocsGPT model if using custom OpenAI-compatible endpoint
if not settings.OPENAI_BASE_URL:
self._add_docsgpt_models(settings)
if (
settings.OPENAI_API_KEY
or (settings.LLM_PROVIDER == "openai" and settings.API_KEY)
or settings.OPENAI_BASE_URL
):
self._add_openai_models(settings)
if settings.OPENAI_API_BASE or (
settings.LLM_PROVIDER == "azure_openai" and settings.API_KEY
):
self._add_azure_openai_models(settings)
if settings.ANTHROPIC_API_KEY or (
settings.LLM_PROVIDER == "anthropic" and settings.API_KEY
):
self._add_anthropic_models(settings)
if settings.GOOGLE_API_KEY or (
settings.LLM_PROVIDER == "google" and settings.API_KEY
):
self._add_google_models(settings)
if settings.GROQ_API_KEY or (
settings.LLM_PROVIDER == "groq" and settings.API_KEY
):
self._add_groq_models(settings)
if settings.OPEN_ROUTER_API_KEY or (
settings.LLM_PROVIDER == "openrouter" and settings.API_KEY
):
self._add_openrouter_models(settings)
if settings.NOVITA_API_KEY or (
settings.LLM_PROVIDER == "novita" and settings.API_KEY
):
self._add_novita_models(settings)
if settings.HUGGINGFACE_API_KEY or (
settings.LLM_PROVIDER == "huggingface" and settings.API_KEY
):
self._add_huggingface_models(settings)
# Default model selection
if settings.LLM_NAME:
# Parse LLM_NAME (may be comma-separated)
model_names = self._parse_model_names(settings.LLM_NAME)
# First model in the list becomes default
for model_name in model_names:
if model_name in self.models:
self.default_model_id = model_name
break
# Backward compat: try exact match if no parsed model found
if not self.default_model_id and settings.LLM_NAME in self.models:
self.default_model_id = settings.LLM_NAME
if not self.default_model_id:
if settings.LLM_PROVIDER and settings.API_KEY:
for model_id, model in self.models.items():
if model.provider.value == settings.LLM_PROVIDER:
self.default_model_id = model_id
break
if not self.default_model_id and self.models:
self.default_model_id = next(iter(self.models.keys()))
logger.info(
f"ModelRegistry loaded {len(self.models)} models, default: {self.default_model_id}"
)
def _add_openai_models(self, settings):
from application.core.model_configs import (
OPENAI_MODELS,
create_custom_openai_model,
)
# Check if using local OpenAI-compatible endpoint (Ollama, LM Studio, etc.)
using_local_endpoint = bool(
settings.OPENAI_BASE_URL and settings.OPENAI_BASE_URL.strip()
)
if using_local_endpoint:
# When OPENAI_BASE_URL is set, ONLY register custom models from LLM_NAME
# Do NOT add standard OpenAI models (gpt-5.1, etc.)
if settings.LLM_NAME:
model_names = self._parse_model_names(settings.LLM_NAME)
for model_name in model_names:
custom_model = create_custom_openai_model(
model_name, settings.OPENAI_BASE_URL
)
self.models[model_name] = custom_model
logger.info(
f"Registered custom OpenAI model: {model_name} at {settings.OPENAI_BASE_URL}"
)
else:
# Standard OpenAI API usage - add standard models if API key is valid
if settings.OPENAI_API_KEY:
for model in OPENAI_MODELS:
self.models[model.id] = model
def _add_azure_openai_models(self, settings):
from application.core.model_configs import AZURE_OPENAI_MODELS
if settings.LLM_PROVIDER == "azure_openai" and settings.LLM_NAME:
for model in AZURE_OPENAI_MODELS:
if model.id == settings.LLM_NAME:
self.models[model.id] = model
return
for model in AZURE_OPENAI_MODELS:
self.models[model.id] = model
def _add_anthropic_models(self, settings):
from application.core.model_configs import ANTHROPIC_MODELS
if settings.ANTHROPIC_API_KEY:
for model in ANTHROPIC_MODELS:
self.models[model.id] = model
return
if settings.LLM_PROVIDER == "anthropic" and settings.LLM_NAME:
for model in ANTHROPIC_MODELS:
if model.id == settings.LLM_NAME:
self.models[model.id] = model
return
for model in ANTHROPIC_MODELS:
self.models[model.id] = model
def _add_google_models(self, settings):
from application.core.model_configs import GOOGLE_MODELS
if settings.GOOGLE_API_KEY:
for model in GOOGLE_MODELS:
self.models[model.id] = model
return
if settings.LLM_PROVIDER == "google" and settings.LLM_NAME:
for model in GOOGLE_MODELS:
if model.id == settings.LLM_NAME:
self.models[model.id] = model
return
for model in GOOGLE_MODELS:
self.models[model.id] = model
def _add_groq_models(self, settings):
from application.core.model_configs import GROQ_MODELS
if settings.GROQ_API_KEY:
for model in GROQ_MODELS:
self.models[model.id] = model
return
if settings.LLM_PROVIDER == "groq" and settings.LLM_NAME:
for model in GROQ_MODELS:
if model.id == settings.LLM_NAME:
self.models[model.id] = model
return
for model in GROQ_MODELS:
self.models[model.id] = model
def _add_openrouter_models(self, settings):
from application.core.model_configs import OPENROUTER_MODELS
if settings.OPEN_ROUTER_API_KEY:
for model in OPENROUTER_MODELS:
self.models[model.id] = model
return
if settings.LLM_PROVIDER == "openrouter" and settings.LLM_NAME:
for model in OPENROUTER_MODELS:
if model.id == settings.LLM_NAME:
self.models[model.id] = model
return
for model in OPENROUTER_MODELS:
self.models[model.id] = model
def _add_novita_models(self, settings):
from application.core.model_configs import NOVITA_MODELS
if settings.NOVITA_API_KEY:
for model in NOVITA_MODELS:
self.models[model.id] = model
return
if settings.LLM_PROVIDER == "novita" and settings.LLM_NAME:
for model in NOVITA_MODELS:
if model.id == settings.LLM_NAME:
self.models[model.id] = model
return
for model in NOVITA_MODELS:
self.models[model.id] = model
def _add_docsgpt_models(self, settings):
model_id = "docsgpt-local"
model = AvailableModel(
id=model_id,
provider=ModelProvider.DOCSGPT,
display_name="DocsGPT Model",
description="Local model",
capabilities=ModelCapabilities(
supports_tools=False,
supported_attachment_types=[],
),
)
self.models[model_id] = model
def _add_huggingface_models(self, settings):
model_id = "huggingface-local"
model = AvailableModel(
id=model_id,
provider=ModelProvider.HUGGINGFACE,
display_name="Hugging Face Model",
description="Local Hugging Face model",
capabilities=ModelCapabilities(
supports_tools=False,
supported_attachment_types=[],
),
)
self.models[model_id] = model
def _parse_model_names(self, llm_name: str) -> List[str]:
"""
Parse LLM_NAME which may contain comma-separated model names.
E.g., 'deepseek-r1:1.5b,gemma:2b' -> ['deepseek-r1:1.5b', 'gemma:2b']
"""
if not llm_name:
return []
return [name.strip() for name in llm_name.split(",") if name.strip()]
def get_model(self, model_id: str) -> Optional[AvailableModel]:
return self.models.get(model_id)
def get_all_models(self) -> List[AvailableModel]:
return list(self.models.values())
def get_enabled_models(self) -> List[AvailableModel]:
return [m for m in self.models.values() if m.enabled]
def model_exists(self, model_id: str) -> bool:
return model_id in self.models
return _MR
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View File

@@ -1,28 +1,22 @@
from typing import Any, Dict, Optional
from application.core.model_settings import ModelRegistry
from application.core.model_registry import ModelRegistry
def get_api_key_for_provider(provider: str) -> Optional[str]:
"""Get the appropriate API key for a provider"""
"""Get the appropriate API key for a provider.
Delegates to the provider plugin's ``get_api_key``. Falls back to the
generic ``settings.API_KEY`` for unknown providers.
"""
from application.core.settings import settings
from application.llm.providers import PROVIDERS_BY_NAME
provider_key_map = {
"openai": settings.OPENAI_API_KEY,
"openrouter": settings.OPEN_ROUTER_API_KEY,
"novita": settings.NOVITA_API_KEY,
"anthropic": settings.ANTHROPIC_API_KEY,
"google": settings.GOOGLE_API_KEY,
"groq": settings.GROQ_API_KEY,
"huggingface": settings.HUGGINGFACE_API_KEY,
"azure_openai": settings.API_KEY,
"docsgpt": None,
"llama.cpp": None,
}
provider_key = provider_key_map.get(provider)
if provider_key:
return provider_key
plugin = PROVIDERS_BY_NAME.get(provider)
if plugin is not None:
key = plugin.get_api_key(settings)
if key:
return key
return settings.API_KEY
@@ -91,3 +85,21 @@ def get_base_url_for_model(model_id: str) -> Optional[str]:
if model:
return model.base_url
return None
def get_api_key_for_model(model_id: str) -> Optional[str]:
"""
Resolve the API key to use when invoking ``model_id``.
Priority:
1. The model record's own ``api_key`` (reserved for future end-user
BYOM where credentials travel with the record).
2. The provider plugin's settings-based key.
"""
registry = ModelRegistry.get_instance()
model = registry.get_model(model_id)
if model is not None and model.api_key:
return model.api_key
if model is not None:
return get_api_key_for_provider(model.provider.value)
return None

View File

@@ -0,0 +1,325 @@
"""YAML loader for model catalog files under ``application/core/models/``.
Each ``*.yaml`` file declares one provider's static model catalog. Files
are validated with Pydantic at load time; any parse, schema, or alias
error aborts startup with the offending file path in the message.
For most providers, one YAML maps to one catalog. The
``openai_compatible`` provider is special: each YAML file represents a
distinct logical endpoint (Mistral, Together, Ollama, ...) with its own
``api_key_env`` and ``base_url``. The loader returns a flat list so the
registry can distinguish multiple files with the same ``provider:`` value.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Dict, List, Optional, Sequence
import yaml
from pydantic import BaseModel, ConfigDict, Field, field_validator
from application.core.model_settings import (
AvailableModel,
ModelCapabilities,
ModelProvider,
)
logger = logging.getLogger(__name__)
BUILTIN_MODELS_DIR = Path(__file__).parent / "models"
DEFAULTS_FILENAME = "_defaults.yaml"
class _DefaultsFile(BaseModel):
"""Schema for ``_defaults.yaml``. Currently just attachment aliases."""
model_config = ConfigDict(extra="forbid")
attachment_aliases: Dict[str, List[str]] = Field(default_factory=dict)
class _CapabilityFields(BaseModel):
"""Capability fields shared between provider ``defaults:`` and per-model overrides.
All fields are optional so a per-model override can selectively replace
a single field from the provider-level defaults.
"""
model_config = ConfigDict(extra="forbid")
supports_tools: Optional[bool] = None
supports_structured_output: Optional[bool] = None
supports_streaming: Optional[bool] = None
attachments: Optional[List[str]] = None
context_window: Optional[int] = None
input_cost_per_token: Optional[float] = None
output_cost_per_token: Optional[float] = None
class _ModelEntry(_CapabilityFields):
"""Schema for one model row inside a YAML's ``models:`` list."""
id: str
display_name: Optional[str] = None
description: str = ""
enabled: bool = True
base_url: Optional[str] = None
aliases: List[str] = Field(default_factory=list)
@field_validator("id")
@classmethod
def _id_nonempty(cls, v: str) -> str:
if not v or not v.strip():
raise ValueError("model id must be a non-empty string")
return v
class _ProviderFile(BaseModel):
"""Schema for one ``<provider>.yaml`` catalog file."""
model_config = ConfigDict(extra="forbid")
provider: str
defaults: _CapabilityFields = Field(default_factory=_CapabilityFields)
models: List[_ModelEntry] = Field(default_factory=list)
# openai_compatible metadata. Optional for other providers.
display_provider: Optional[str] = None
api_key_env: Optional[str] = None
base_url: Optional[str] = None
class ProviderCatalog(BaseModel):
"""One YAML file's parsed contents, ready for the registry.
For most providers, multiple catalogs with the same ``provider`` get
merged later by the registry. The ``openai_compatible`` provider is
the exception: each catalog is treated as a distinct endpoint, with
its own ``api_key_env`` and ``base_url``.
"""
provider: str
models: List[AvailableModel]
source_path: Optional[Path] = None
display_provider: Optional[str] = None
api_key_env: Optional[str] = None
base_url: Optional[str] = None
model_config = ConfigDict(arbitrary_types_allowed=True)
class ModelYAMLError(ValueError):
"""Raised when a model YAML fails parsing, schema, or alias validation."""
def _expand_attachments(
attachments: Sequence[str], aliases: Dict[str, List[str]], source: str
) -> List[str]:
"""Resolve attachment shorthands (``image``, ``pdf``) to MIME types.
Raw MIME-typed entries (containing ``/``) pass through unchanged.
Unknown aliases raise ``ModelYAMLError``.
"""
expanded: List[str] = []
seen: set = set()
for entry in attachments:
if "/" in entry:
if entry not in seen:
expanded.append(entry)
seen.add(entry)
continue
if entry not in aliases:
valid = ", ".join(sorted(aliases.keys())) or "<none defined>"
raise ModelYAMLError(
f"{source}: unknown attachment alias '{entry}'. "
f"Valid aliases: {valid}. "
"(Or use a raw MIME type like 'image/png'.)"
)
for mime in aliases[entry]:
if mime not in seen:
expanded.append(mime)
seen.add(mime)
return expanded
def _load_defaults(directory: Path) -> Dict[str, List[str]]:
"""Load ``_defaults.yaml`` from ``directory`` if it exists."""
path = directory / DEFAULTS_FILENAME
if not path.exists():
return {}
try:
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except yaml.YAMLError as e:
raise ModelYAMLError(f"{path}: invalid YAML: {e}") from e
try:
parsed = _DefaultsFile.model_validate(raw)
except Exception as e:
raise ModelYAMLError(f"{path}: schema error: {e}") from e
return parsed.attachment_aliases
def _resolve_provider_enum(name: str, source: Path) -> ModelProvider:
try:
return ModelProvider(name)
except ValueError as e:
valid = ", ".join(p.value for p in ModelProvider)
raise ModelYAMLError(
f"{source}: unknown provider '{name}'. Valid: {valid}"
) from e
def _build_model(
entry: _ModelEntry,
defaults: _CapabilityFields,
provider: ModelProvider,
aliases: Dict[str, List[str]],
source: Path,
display_provider: Optional[str] = None,
) -> AvailableModel:
"""Merge defaults + per-model overrides into a final ``AvailableModel``."""
def pick(field_name: str, fallback):
v = getattr(entry, field_name)
if v is not None:
return v
d = getattr(defaults, field_name)
if d is not None:
return d
return fallback
raw_attachments = entry.attachments
if raw_attachments is None:
raw_attachments = defaults.attachments
if raw_attachments is None:
raw_attachments = []
expanded = _expand_attachments(
raw_attachments, aliases, f"{source} [model={entry.id}]"
)
caps = ModelCapabilities(
supports_tools=pick("supports_tools", False),
supports_structured_output=pick("supports_structured_output", False),
supports_streaming=pick("supports_streaming", True),
supported_attachment_types=expanded,
context_window=pick("context_window", 128000),
input_cost_per_token=pick("input_cost_per_token", None),
output_cost_per_token=pick("output_cost_per_token", None),
)
return AvailableModel(
id=entry.id,
provider=provider,
display_name=entry.display_name or entry.id,
description=entry.description,
capabilities=caps,
enabled=entry.enabled,
base_url=entry.base_url,
display_provider=display_provider,
)
def _load_one_yaml(
path: Path, aliases: Dict[str, List[str]]
) -> ProviderCatalog:
try:
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except yaml.YAMLError as e:
raise ModelYAMLError(f"{path}: invalid YAML: {e}") from e
try:
parsed = _ProviderFile.model_validate(raw)
except Exception as e:
raise ModelYAMLError(f"{path}: schema error: {e}") from e
provider_enum = _resolve_provider_enum(parsed.provider, path)
models = [
_build_model(
entry,
parsed.defaults,
provider_enum,
aliases,
path,
display_provider=parsed.display_provider,
)
for entry in parsed.models
]
return ProviderCatalog(
provider=parsed.provider,
models=models,
source_path=path,
display_provider=parsed.display_provider,
api_key_env=parsed.api_key_env,
base_url=parsed.base_url,
)
_BUILTIN_ALIASES_CACHE: Optional[Dict[str, List[str]]] = None
def builtin_attachment_aliases() -> Dict[str, List[str]]:
"""Return the built-in attachment alias map from ``_defaults.yaml``.
Cached after first read so repeat calls are cheap.
"""
global _BUILTIN_ALIASES_CACHE
if _BUILTIN_ALIASES_CACHE is None:
_BUILTIN_ALIASES_CACHE = _load_defaults(BUILTIN_MODELS_DIR)
return _BUILTIN_ALIASES_CACHE
def resolve_attachment_alias(alias: str) -> List[str]:
"""Resolve a single attachment alias (e.g. ``"image"``) to its
canonical MIME-type list. Raises ``ModelYAMLError`` if unknown.
"""
aliases = builtin_attachment_aliases()
if alias not in aliases:
valid = ", ".join(sorted(aliases.keys())) or "<none defined>"
raise ModelYAMLError(
f"Unknown attachment alias '{alias}'. Valid: {valid}"
)
return list(aliases[alias])
def load_model_yamls(directories: Sequence[Path]) -> List[ProviderCatalog]:
"""Load every ``*.yaml`` file (excluding ``_defaults.yaml``) under each
directory in order and return a flat list of catalogs.
Caller is responsible for merging multiple catalogs that target the
same provider plugin. The flat-list shape lets ``openai_compatible``
keep each file separate (one logical endpoint per file).
When the same model ``id`` appears in more than one YAML across the
directory list, a warning is logged. Order in the returned list
preserves load order, so the registry's "later wins" merge gives the
later directory's definition.
"""
catalogs: List[ProviderCatalog] = []
seen_ids: Dict[str, Path] = {}
aliases: Dict[str, List[str]] = {}
for d in directories:
if not d or not d.exists():
continue
aliases.update(_load_defaults(d))
for d in directories:
if not d or not d.exists():
continue
for path in sorted(d.glob("*.yaml")):
if path.name == DEFAULTS_FILENAME:
continue
catalog = _load_one_yaml(path, aliases)
catalogs.append(catalog)
for m in catalog.models:
prior = seen_ids.get(m.id)
if prior is not None and prior != path:
logger.warning(
"Model id %r redefined: %s overrides %s (later wins)",
m.id,
path,
prior,
)
seen_ids[m.id] = path
return catalogs

View File

@@ -0,0 +1,213 @@
# Model catalogs
Each `*.yaml` file in this directory declares one provider's model
catalog. The registry loads every YAML at boot and joins it to the
matching provider plugin under `application/llm/providers/`.
To add or edit models, you almost always only touch a YAML here — no
Python code required.
## Add a model to an existing provider
Open the provider's YAML (e.g. `anthropic.yaml`) and append two lines
under `models:`:
```yaml
models:
- id: claude-3-7-sonnet
display_name: Claude 3.7 Sonnet
```
Capabilities default to the provider's `defaults:` block. Override
per-model only when needed:
```yaml
- id: claude-3-7-sonnet
display_name: Claude 3.7 Sonnet
context_window: 500000
```
Restart the app. The new model appears in `/api/models`.
> The model `id` is what gets stored in agent / workflow records. Once
> users start picking the model, **don't rename it** — agent and
> workflow rows reference it as a free-form string and silently fall
> back to the system default if the id disappears.
## Add an OpenAI-compatible provider (zero Python)
Drop a YAML in this directory (or in your `MODELS_CONFIG_DIR`) that uses
the `openai_compatible` plugin. Set the env var named in `api_key_env`
and you're done — no Python, no settings.py edit, no LLMCreator change:
```yaml
# mistral.yaml
provider: openai_compatible
display_provider: mistral # shown in /api/models response
api_key_env: MISTRAL_API_KEY # env var the plugin reads at boot
base_url: https://api.mistral.ai/v1
defaults:
supports_tools: true
context_window: 128000
models:
- id: mistral-large-latest
display_name: Mistral Large
- id: mistral-small-latest
display_name: Mistral Small
```
`MISTRAL_API_KEY=sk-... ; restart` — Mistral models appear in
`/api/models` with `provider: "mistral"`. They route through the OpenAI
wire format (it's `OpenAILLM` under the hood) but with Mistral's
endpoint and key.
Multiple `openai_compatible` YAMLs coexist: each file is one logical
endpoint with its own `api_key_env` and `base_url`. Drop in
`together.yaml`, `fireworks.yaml`, etc. side by side. If an env var
isn't set, that catalog is silently skipped at boot (logged at INFO) —
no error.
Working example: `examples/mistral.yaml.example`. Files inside
`examples/` aren't loaded by the registry; the glob only picks up
`*.yaml` at the top level.
## Add a provider with its own SDK
For a provider that doesn't speak OpenAI's wire format, add one Python
file to `application/llm/providers/<name>.py`:
```python
from application.llm.providers.base import Provider
from application.llm.my_provider import MyLLM
class MyProvider(Provider):
name = "my_provider"
llm_class = MyLLM
def get_api_key(self, settings):
return settings.MY_PROVIDER_API_KEY
```
Register it in `application/llm/providers/__init__.py` (one line in
`ALL_PROVIDERS`), add `MY_PROVIDER_API_KEY` to `settings.py`, and create
`my_provider.yaml` here with the model catalog.
## Schema reference
```yaml
provider: <string, required> # matches the Provider plugin's `name`
# openai_compatible only — required for that provider, ignored for others
display_provider: <string> # label shown in /api/models response
api_key_env: <string> # name of the env var carrying the key
base_url: <string> # endpoint URL
defaults: # optional, applied to every model below
supports_tools: bool # default false
supports_structured_output: bool # default false
supports_streaming: bool # default true
attachments: [<alias-or-mime>, ...] # default []
context_window: int # default 128000
input_cost_per_token: float # default null
output_cost_per_token: float # default null
models: # required
- id: <string, required> # the value persisted in agent records
display_name: <string> # default: id
description: <string> # default: ""
enabled: bool # default true; false hides from /api/models
base_url: <string> # optional custom endpoint for this model
# All `defaults:` fields above can be overridden here per-model.
```
### Attachment aliases
The `attachments:` list can mix human-readable aliases with raw MIME
types. Aliases are defined in `_defaults.yaml`:
| Alias | Expands to |
|---|---|
| `image` | `image/png`, `image/jpeg`, `image/jpg`, `image/webp`, `image/gif` |
| `pdf` | `application/pdf` |
| `audio` | `audio/mpeg`, `audio/wav`, `audio/ogg` |
Use raw MIME types when you need surgical control:
```yaml
attachments: [image/png, image/webp] # only these two
```
## Operator-supplied YAMLs (`MODELS_CONFIG_DIR`)
Set the `MODELS_CONFIG_DIR` env var (or `.env` entry) to a directory
path. Every `*.yaml` in that directory is loaded **after** the built-in
catalog under `application/core/models/`. Operators use this to:
- Add new `openai_compatible` providers (Mistral, Together, Fireworks,
Ollama, ...) without forking the repo.
- Extend an existing provider's catalog with extra models — append
models under `provider: anthropic` and they show up alongside the
built-ins.
- Override a built-in model's capabilities — declare the same `id`
with different fields (e.g. a higher `context_window`). Later wins;
the override is logged as a `WARNING` so you can audit it.
Things you cannot do via `MODELS_CONFIG_DIR`:
- Add a brand-new non-OpenAI provider — that needs a Python plugin
under `application/llm/providers/` (see "Add a provider with its own
SDK" above). Operator YAMLs may only target a `provider:` value that
already has a registered plugin.
### Example: Docker
Mount your model YAMLs into the container and point the env var at the
mount path:
```yaml
# docker-compose.yml
services:
app:
image: arc53/docsgpt
environment:
MODELS_CONFIG_DIR: /etc/docsgpt/models
MISTRAL_API_KEY: ${MISTRAL_API_KEY}
volumes:
- ./my-models:/etc/docsgpt/models:ro
```
Then `./my-models/mistral.yaml` (the file from
`examples/mistral.yaml.example`) gets picked up at boot.
### Example: Kubernetes
Mount a `ConfigMap` containing your YAMLs at a known path and set
`MODELS_CONFIG_DIR` on the deployment. The same `examples/mistral.yaml.example`
becomes a key in the ConfigMap.
### Misconfiguration
If `MODELS_CONFIG_DIR` is set but the path doesn't exist (or isn't a
directory), the app logs a `WARNING` at boot and continues with just
the built-in catalog. The app does *not* fail to start — operators can
ship config drift without taking down the service — but the warning is
loud enough to surface in any reasonable log aggregator.
## Validation
YAMLs are parsed with Pydantic at boot. The app fails to start with a
clear error message if:
- a top-level key is unknown
- a model is missing `id`
- an attachment alias isn't defined
- the `provider:` value isn't registered as a plugin
This is intentional — silent fallbacks would mean users don't notice
their model picks broke until they hit the API.
## Reserved fields (not yet implemented)
- `aliases:` on a model — old IDs that resolve to this model. Reserved
for future renames; the schema accepts the field but it is not yet
acted on.

View File

@@ -0,0 +1,18 @@
# Global defaults applied across every model YAML in this directory.
# Keep this file sparse — per-provider `defaults:` blocks are clearer
# than a deep global default chain. This file is for things that
# genuinely never vary, like the meaning of "image".
attachment_aliases:
image:
- image/png
- image/jpeg
- image/jpg
- image/webp
- image/gif
pdf:
- application/pdf
audio:
- audio/mpeg
- audio/wav
- audio/ogg

View File

@@ -0,0 +1,23 @@
provider: anthropic
defaults:
supports_tools: true
attachments: [image]
context_window: 200000
models:
- id: claude-opus-4-7
display_name: Claude Opus 4.7
description: Most capable Claude model for complex reasoning and agentic coding
context_window: 1000000
supports_structured_output: true
- id: claude-sonnet-4-6
display_name: Claude Sonnet 4.6
description: Best balance of speed and intelligence with extended thinking
context_window: 1000000
supports_structured_output: true
- id: claude-haiku-4-5
display_name: Claude Haiku 4.5
description: Fastest Claude model with near-frontier intelligence
supports_structured_output: true

View File

@@ -0,0 +1,31 @@
# Azure OpenAI catalog.
#
# IMPORTANT: For Azure OpenAI, the `id` field is the **deployment name**, not
# a model name. Deployment names are arbitrary strings the operator chooses
# in Azure portal (or via ARM/Bicep/Terraform) when they create a deployment
# for a given underlying model + version.
#
# The IDs below are sensible defaults that mirror the underlying OpenAI
# model name (prefixed with `azure-`). Operators almost always need to
# override them via `MODELS_CONFIG_DIR` to match the deployment names that
# actually exist in their Azure resource. The `display_name`, capability
# flags, and `context_window` reflect the underlying OpenAI model.
provider: azure_openai
defaults:
supports_tools: true
supports_structured_output: true
attachments: [image]
context_window: 400000
models:
- id: azure-gpt-5.5
display_name: Azure OpenAI GPT-5.5
description: Azure-hosted flagship frontier model for complex reasoning, coding, and agentic work with a 1M-token context window
context_window: 1050000
- id: azure-gpt-5.4-mini
display_name: Azure OpenAI GPT-5.4 Mini
description: Azure-hosted cost-efficient GPT-5.4-class model for high-volume coding, computer use, and subagent workloads
- id: azure-gpt-5.4-nano
display_name: Azure OpenAI GPT-5.4 Nano
description: Azure-hosted cheapest GPT-5.4-class model, optimized for simple high-volume tasks where speed and cost matter most

View File

@@ -0,0 +1,7 @@
provider: docsgpt
models:
- id: docsgpt-local
display_name: DocsGPT Model
description: Local model
supports_tools: false

View File

@@ -0,0 +1,31 @@
# EXAMPLE — copy this file to ../mistral.yaml (or to your
# MODELS_CONFIG_DIR) and set MISTRAL_API_KEY in your environment.
#
# This is the entire integration. No Python required: the
# `openai_compatible` plugin reads `api_key_env` and `base_url` from
# the file and routes calls through the OpenAI wire format.
#
# Files in this `examples/` directory are NOT loaded by the registry
# (the loader globs *.yaml at the top level only).
provider: openai_compatible
display_provider: mistral # shown in /api/models response
api_key_env: MISTRAL_API_KEY # env var the plugin reads
base_url: https://api.mistral.ai/v1 # OpenAI-compatible endpoint
defaults:
supports_tools: true
context_window: 128000
models:
- id: mistral-large-latest
display_name: Mistral Large
description: Top-tier reasoning model
- id: mistral-small-latest
display_name: Mistral Small
description: Fast, cost-efficient
- id: codestral-latest
display_name: Codestral
description: Code-specialized model

View File

@@ -0,0 +1,17 @@
provider: google
defaults:
supports_tools: true
supports_structured_output: true
attachments: [pdf, image]
context_window: 1048576
models:
- id: gemini-3.1-pro-preview
display_name: Gemini 3.1 Pro
description: Most capable Gemini 3 model with advanced reasoning and agentic coding (preview)
- id: gemini-3-flash-preview
display_name: Gemini 3 Flash
description: Frontier-class performance for low-latency, high-volume tasks (preview)
- id: gemini-3.1-flash-lite-preview
display_name: Gemini 3.1 Flash-Lite
description: Cost-efficient frontier-class multimodal model for high-throughput workloads (preview)

View File

@@ -0,0 +1,16 @@
provider: groq
defaults:
supports_tools: true
context_window: 131072
models:
- id: openai/gpt-oss-120b
display_name: GPT-OSS 120B
description: OpenAI's open-weight 120B flagship served on Groq's LPU hardware; strong general reasoning with strict structured output support
supports_structured_output: true
- id: llama-3.3-70b-versatile
display_name: Llama 3.3 70B Versatile
description: Meta's Llama 3.3 70B for general-purpose chat with parallel tool use
- id: llama-3.1-8b-instant
display_name: Llama 3.1 8B Instant
description: Small, very low-latency Llama model (~560 tok/s) with parallel tool use

View File

@@ -0,0 +1,7 @@
provider: huggingface
models:
- id: huggingface-local
display_name: Hugging Face Model
description: Local Hugging Face model
supports_tools: false

View File

@@ -0,0 +1,21 @@
provider: novita
defaults:
supports_tools: true
supports_structured_output: true
models:
- id: deepseek/deepseek-v4-pro
display_name: DeepSeek V4 Pro
description: 1.6T MoE (49B active) with 1M context, hybrid CSA/HCA attention, top-tier reasoning and agentic coding
context_window: 1048576
- id: moonshotai/kimi-k2.6
display_name: Kimi K2.6
description: 1T-parameter open-weight MoE with native vision/video, multi-step tool calling, and agentic long-horizon execution
attachments: [image]
context_window: 262144
- id: zai-org/glm-5
display_name: GLM-5
description: Z.AI 754B-parameter MoE with strong general reasoning, function calling, and structured output
context_window: 202800

View File

@@ -0,0 +1,18 @@
provider: openai
defaults:
supports_tools: true
supports_structured_output: true
attachments: [image]
context_window: 400000
models:
- id: gpt-5.5
display_name: GPT-5.5
description: Flagship frontier model for complex reasoning, coding, and agentic work with a 1M-token context window
context_window: 1050000
- id: gpt-5.4-mini
display_name: GPT-5.4 Mini
description: Cost-efficient GPT-5.4-class model for high-volume coding, computer use, and subagent workloads
- id: gpt-5.4-nano
display_name: GPT-5.4 Nano
description: Cheapest GPT-5.4-class model, optimized for simple high-volume tasks where speed and cost matter most

View File

@@ -0,0 +1,25 @@
provider: openrouter
defaults:
supports_tools: true
attachments: [image]
context_window: 128000
models:
- id: qwen/qwen3-coder:free
display_name: Qwen3 Coder (free)
description: Free-tier 480B MoE coder model with strong agentic tool use; rate-limited
context_window: 262000
attachments: []
- id: deepseek/deepseek-v3.2
display_name: DeepSeek V3.2
description: Open-weights reasoning model, very low cost (~$0.25 in / $0.38 out per 1M)
context_window: 131072
attachments: []
supports_structured_output: true
- id: anthropic/claude-sonnet-4.6
display_name: Claude Sonnet 4.6 (via OpenRouter)
description: Frontier Sonnet-class model with 1M context, vision, and extended thinking
context_window: 1000000
supports_structured_output: true

View File

@@ -23,6 +23,10 @@ class Settings(BaseSettings):
EMBEDDINGS_NAME: str = "huggingface_sentence-transformers/all-mpnet-base-v2"
EMBEDDINGS_BASE_URL: Optional[str] = None # Remote embeddings API URL (OpenAI-compatible)
EMBEDDINGS_KEY: Optional[str] = None # api key for embeddings (if using openai, just copy API_KEY)
# Optional directory of operator-supplied model YAMLs, loaded after the
# built-in catalog under application/core/models/. Later wins on
# duplicate model id. See application/core/models/README.md.
MODELS_CONFIG_DIR: Optional[str] = None
CELERY_BROKER_URL: str = "redis://localhost:6379/0"
CELERY_RESULT_BACKEND: str = "redis://localhost:6379/1"
@@ -149,6 +153,9 @@ class Settings(BaseSettings):
FLASK_DEBUG_MODE: bool = False
STORAGE_TYPE: str = "local" # local or s3
# Anonymous startup version check for security issues.
VERSION_CHECK: bool = True
URL_STRATEGY: str = "backend" # backend or s3
JWT_SECRET_KEY: str = ""

View File

@@ -0,0 +1,72 @@
"""Gunicorn config — keeps uvicorn's access log in NCSA format."""
from __future__ import annotations
import logging
import logging.config
# NCSA common log format:
# %(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"
# Uvicorn's access formatter exposes a ``client_addr``/``request_line``/
# ``status_code`` trio but not the full NCSA field set, so we re-derive
# what we can.
_NCSA_FMT = (
'%(client_addr)s - - [%(asctime)s] "%(request_line)s" %(status_code)s'
)
logconfig_dict = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"ncsa_access": {
"()": "uvicorn.logging.AccessFormatter",
"fmt": _NCSA_FMT,
"datefmt": "%d/%b/%Y:%H:%M:%S %z",
"use_colors": False,
},
"default": {
"format": "[%(asctime)s] [%(process)d] [%(levelname)s] %(name)s: %(message)s",
},
},
"handlers": {
"access": {
"class": "logging.StreamHandler",
"formatter": "ncsa_access",
"stream": "ext://sys.stdout",
},
"default": {
"class": "logging.StreamHandler",
"formatter": "default",
"stream": "ext://sys.stderr",
},
},
"loggers": {
"uvicorn": {"handlers": ["default"], "level": "INFO", "propagate": False},
"uvicorn.error": {
"handlers": ["default"],
"level": "INFO",
"propagate": False,
},
"uvicorn.access": {
"handlers": ["access"],
"level": "INFO",
"propagate": False,
},
"gunicorn.error": {
"handlers": ["default"],
"level": "INFO",
"propagate": False,
},
"gunicorn.access": {
"handlers": ["access"],
"level": "INFO",
"propagate": False,
},
},
"root": {"handlers": ["default"], "level": "INFO"},
}
def on_starting(server): # pragma: no cover — gunicorn hook
"""Ensure gunicorn's own loggers use the configured handlers."""
logging.config.dictConfig(logconfig_dict)

View File

@@ -1,34 +1,11 @@
import logging
from application.llm.anthropic import AnthropicLLM
from application.llm.docsgpt_provider import DocsGPTAPILLM
from application.llm.google_ai import GoogleLLM
from application.llm.groq import GroqLLM
from application.llm.llama_cpp import LlamaCpp
from application.llm.novita import NovitaLLM
from application.llm.openai import AzureOpenAILLM, OpenAILLM
from application.llm.premai import PremAILLM
from application.llm.sagemaker import SagemakerAPILLM
from application.llm.open_router import OpenRouterLLM
from application.llm.providers import PROVIDERS_BY_NAME
logger = logging.getLogger(__name__)
class LLMCreator:
llms = {
"openai": OpenAILLM,
"azure_openai": AzureOpenAILLM,
"sagemaker": SagemakerAPILLM,
"llama.cpp": LlamaCpp,
"anthropic": AnthropicLLM,
"docsgpt": DocsGPTAPILLM,
"premai": PremAILLM,
"groq": GroqLLM,
"google": GoogleLLM,
"novita": NovitaLLM,
"openrouter": OpenRouterLLM,
}
@classmethod
def create_llm(
cls,
@@ -42,18 +19,27 @@ class LLMCreator:
*args,
**kwargs,
):
from application.core.model_utils import get_base_url_for_model
from application.core.model_registry import ModelRegistry
llm_class = cls.llms.get(type.lower())
if not llm_class:
plugin = PROVIDERS_BY_NAME.get(type.lower())
if plugin is None or plugin.llm_class is None:
raise ValueError(f"No LLM class found for type {type}")
# Extract base_url from model configuration if model_id is provided
# Prefer per-model endpoint config from the registry. This is what
# makes openai_compatible (and the future end-user BYOM phase)
# work without changing every call site: if the registered
# AvailableModel carries its own api_key / base_url, they win
# over whatever the caller resolved via the provider plugin.
base_url = None
if model_id:
base_url = get_base_url_for_model(model_id)
model = ModelRegistry.get_instance().get_model(model_id)
if model is not None:
if model.api_key:
api_key = model.api_key
if model.base_url:
base_url = model.base_url
return llm_class(
return plugin.llm_class(
api_key,
user_api_key,
decoded_token=decoded_token,

View File

@@ -389,8 +389,8 @@ class OpenAILLM(BaseLLM):
Returns:
list: List of supported MIME types
"""
from application.core.model_configs import OPENAI_ATTACHMENTS
return OPENAI_ATTACHMENTS
from application.core.model_yaml import resolve_attachment_alias
return resolve_attachment_alias("image")
def prepare_messages_with_attachments(self, messages, attachments=None):
"""

View File

@@ -0,0 +1,51 @@
"""Provider plugin registry.
Plugins are imported eagerly so import errors surface at app boot rather
than at first request. ``ALL_PROVIDERS`` is the canonical ordered list;
``PROVIDERS_BY_NAME`` is a name-keyed lookup for LLMCreator and the
model registry.
"""
from __future__ import annotations
from typing import Dict, List
from application.llm.providers.anthropic import AnthropicProvider
from application.llm.providers.azure_openai import AzureOpenAIProvider
from application.llm.providers.base import Provider
from application.llm.providers.docsgpt import DocsGPTProvider
from application.llm.providers.google import GoogleProvider
from application.llm.providers.groq import GroqProvider
from application.llm.providers.huggingface import HuggingFaceProvider
from application.llm.providers.llama_cpp import LlamaCppProvider
from application.llm.providers.novita import NovitaProvider
from application.llm.providers.openai import OpenAIProvider
from application.llm.providers.openai_compatible import OpenAICompatibleProvider
from application.llm.providers.openrouter import OpenRouterProvider
from application.llm.providers.premai import PremAIProvider
from application.llm.providers.sagemaker import SagemakerProvider
# Order here is the order the registry iterates providers (and therefore
# the order ``/api/models`` reports them). Match the historical order
# from the old ModelRegistry._load_models for byte-stable output during
# the migration. ``openai_compatible`` slots in right after ``openai``
# so legacy ``OPENAI_BASE_URL`` models keep landing in the same place.
ALL_PROVIDERS: List[Provider] = [
DocsGPTProvider(),
OpenAIProvider(),
OpenAICompatibleProvider(),
AzureOpenAIProvider(),
AnthropicProvider(),
GoogleProvider(),
GroqProvider(),
OpenRouterProvider(),
NovitaProvider(),
HuggingFaceProvider(),
LlamaCppProvider(),
PremAIProvider(),
SagemakerProvider(),
]
PROVIDERS_BY_NAME: Dict[str, Provider] = {p.name: p for p in ALL_PROVIDERS}
__all__ = ["ALL_PROVIDERS", "PROVIDERS_BY_NAME", "Provider"]

View File

@@ -0,0 +1,51 @@
"""Shared helper for providers that follow the
``<X>_API_KEY or (LLM_PROVIDER==X and API_KEY)`` pattern.
This is the dominant pattern across Anthropic, Google, Groq, OpenRouter,
and Novita. Extracted here so each plugin stays a few lines long.
"""
from __future__ import annotations
from typing import List, Optional
from application.core.model_settings import AvailableModel
def get_api_key(
settings,
provider_name: str,
provider_specific_key: Optional[str],
) -> Optional[str]:
if provider_specific_key:
return provider_specific_key
if settings.LLM_PROVIDER == provider_name and settings.API_KEY:
return settings.API_KEY
return None
def filter_models_by_llm_name(
settings,
provider_name: str,
provider_specific_key: Optional[str],
models: List[AvailableModel],
) -> List[AvailableModel]:
"""Mirrors the historical ``_add_<X>_models`` selection logic.
Behavior:
- If the provider-specific API key is set → load all models.
- Else if ``LLM_PROVIDER`` matches and ``LLM_NAME`` matches a known
model → load just that model.
- Otherwise → load all models (preserved "load anyway" branch from
the original methods).
"""
if provider_specific_key:
return models
if (
settings.LLM_PROVIDER == provider_name
and settings.LLM_NAME
):
named = [m for m in models if m.id == settings.LLM_NAME]
if named:
return named
return models

View File

@@ -0,0 +1,23 @@
from __future__ import annotations
from typing import Optional
from application.llm.anthropic import AnthropicLLM
from application.llm.providers._apikey_or_llm_name import (
filter_models_by_llm_name,
get_api_key,
)
from application.llm.providers.base import Provider
class AnthropicProvider(Provider):
name = "anthropic"
llm_class = AnthropicLLM
def get_api_key(self, settings) -> Optional[str]:
return get_api_key(settings, self.name, settings.ANTHROPIC_API_KEY)
def filter_yaml_models(self, settings, models):
return filter_models_by_llm_name(
settings, self.name, settings.ANTHROPIC_API_KEY, models
)

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
from typing import Optional
from application.llm.openai import AzureOpenAILLM
from application.llm.providers.base import Provider
class AzureOpenAIProvider(Provider):
name = "azure_openai"
llm_class = AzureOpenAILLM
def get_api_key(self, settings) -> Optional[str]:
# Azure historically uses the generic API_KEY field.
return settings.API_KEY
def is_enabled(self, settings) -> bool:
if settings.OPENAI_API_BASE:
return True
return settings.LLM_PROVIDER == self.name and bool(settings.API_KEY)
def filter_yaml_models(self, settings, models):
# Mirrors _add_azure_openai_models: when LLM_PROVIDER==azure_openai
# and LLM_NAME matches a known model, narrow to that one model.
# Otherwise load the entire catalog.
if settings.LLM_PROVIDER == self.name and settings.LLM_NAME:
named = [m for m in models if m.id == settings.LLM_NAME]
if named:
return named
return models

View File

@@ -0,0 +1,74 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, ClassVar, List, Optional, Type
if TYPE_CHECKING:
from application.core.model_settings import AvailableModel
from application.core.model_yaml import ProviderCatalog
from application.core.settings import Settings
from application.llm.base import BaseLLM
class Provider(ABC):
"""Owns the *behavior* of an LLM provider.
Concrete providers declare their name, the LLM class to instantiate,
and how to resolve credentials from settings. Static model catalogs
live in YAML under ``application/core/models/`` and are joined to the
provider by name at registry load time.
Most plugins receive zero or one catalog at registry-build time. The
``openai_compatible`` plugin is the exception: it receives one catalog
per matching YAML file, each with its own ``api_key_env`` and
``base_url``. Plugins that need per-catalog metadata override
``get_models``; the default implementation merges catalogs and routes
through ``filter_yaml_models`` + ``extra_models``.
"""
name: ClassVar[str]
# ``None`` means the provider appears in the catalog but isn't
# dispatchable through LLMCreator (e.g. Hugging Face today, where the
# original LLMCreator dict had no entry).
llm_class: ClassVar[Optional[Type["BaseLLM"]]] = None
@abstractmethod
def get_api_key(self, settings: "Settings") -> Optional[str]:
"""Return the API key for this provider, or None if unavailable."""
def is_enabled(self, settings: "Settings") -> bool:
"""Whether this provider should contribute models to the registry."""
return bool(self.get_api_key(settings))
def filter_yaml_models(
self, settings: "Settings", models: List["AvailableModel"]
) -> List["AvailableModel"]:
"""Hook to filter YAML-loaded models. Default: return all."""
return models
def extra_models(self, settings: "Settings") -> List["AvailableModel"]:
"""Hook to add dynamic models not declared in YAML. Default: none."""
return []
def get_models(
self,
settings: "Settings",
catalogs: List["ProviderCatalog"],
) -> List["AvailableModel"]:
"""Final list of models this plugin contributes.
Default: merge the models across all matched catalogs (later
catalog wins on duplicate id), filter via ``filter_yaml_models``,
then append ``extra_models``. Override when per-catalog metadata
matters (see ``OpenAICompatibleProvider``).
"""
merged: List["AvailableModel"] = []
seen: dict = {}
for c in catalogs:
for m in c.models:
if m.id in seen:
merged[seen[m.id]] = m
else:
seen[m.id] = len(merged)
merged.append(m)
return self.filter_yaml_models(settings, merged) + self.extra_models(settings)

View File

@@ -0,0 +1,22 @@
from __future__ import annotations
from typing import Optional
from application.llm.docsgpt_provider import DocsGPTAPILLM
from application.llm.providers.base import Provider
class DocsGPTProvider(Provider):
name = "docsgpt"
llm_class = DocsGPTAPILLM
def get_api_key(self, settings) -> Optional[str]:
# No provider-specific key; the LLM class can use the generic
# API_KEY fallback if it needs one. Mirrors model_utils' historical
# behavior of returning settings.API_KEY when no specific key exists.
return settings.API_KEY
def is_enabled(self, settings) -> bool:
# The hosted DocsGPT model is hidden when the deployment is
# pointed at a custom OpenAI-compatible endpoint.
return not settings.OPENAI_BASE_URL

View File

@@ -0,0 +1,23 @@
from __future__ import annotations
from typing import Optional
from application.llm.google_ai import GoogleLLM
from application.llm.providers._apikey_or_llm_name import (
filter_models_by_llm_name,
get_api_key,
)
from application.llm.providers.base import Provider
class GoogleProvider(Provider):
name = "google"
llm_class = GoogleLLM
def get_api_key(self, settings) -> Optional[str]:
return get_api_key(settings, self.name, settings.GOOGLE_API_KEY)
def filter_yaml_models(self, settings, models):
return filter_models_by_llm_name(
settings, self.name, settings.GOOGLE_API_KEY, models
)

View File

@@ -0,0 +1,23 @@
from __future__ import annotations
from typing import Optional
from application.llm.groq import GroqLLM
from application.llm.providers._apikey_or_llm_name import (
filter_models_by_llm_name,
get_api_key,
)
from application.llm.providers.base import Provider
class GroqProvider(Provider):
name = "groq"
llm_class = GroqLLM
def get_api_key(self, settings) -> Optional[str]:
return get_api_key(settings, self.name, settings.GROQ_API_KEY)
def filter_yaml_models(self, settings, models):
return filter_models_by_llm_name(
settings, self.name, settings.GROQ_API_KEY, models
)

View File

@@ -0,0 +1,25 @@
from __future__ import annotations
from typing import Optional
from application.llm.providers._apikey_or_llm_name import (
get_api_key as shared_get_api_key,
)
from application.llm.providers.base import Provider
class HuggingFaceProvider(Provider):
"""Surfaces ``huggingface-local`` to the model catalog.
Not dispatchable through LLMCreator — historically there was no
HuggingFaceLLM entry in ``LLMCreator.llms``, and calling ``create_llm``
with ``"huggingface"`` raised ``ValueError``. We preserve that
behavior: the model appears in ``/api/models`` but selecting it
surfaces the same error it always did.
"""
name = "huggingface"
llm_class = None # not dispatchable
def get_api_key(self, settings) -> Optional[str]:
return shared_get_api_key(settings, self.name, settings.HUGGINGFACE_API_KEY)

View File

@@ -0,0 +1,19 @@
from __future__ import annotations
from typing import Optional
from application.llm.llama_cpp import LlamaCpp
from application.llm.providers.base import Provider
class LlamaCppProvider(Provider):
"""LLMCreator-only plugin: invocable via LLM_PROVIDER but not in the catalog."""
name = "llama.cpp"
llm_class = LlamaCpp
def get_api_key(self, settings) -> Optional[str]:
return settings.API_KEY
def is_enabled(self, settings) -> bool:
return False

View File

@@ -0,0 +1,23 @@
from __future__ import annotations
from typing import Optional
from application.llm.novita import NovitaLLM
from application.llm.providers._apikey_or_llm_name import (
filter_models_by_llm_name,
get_api_key,
)
from application.llm.providers.base import Provider
class NovitaProvider(Provider):
name = "novita"
llm_class = NovitaLLM
def get_api_key(self, settings) -> Optional[str]:
return get_api_key(settings, self.name, settings.NOVITA_API_KEY)
def filter_yaml_models(self, settings, models):
return filter_models_by_llm_name(
settings, self.name, settings.NOVITA_API_KEY, models
)

View File

@@ -0,0 +1,37 @@
from __future__ import annotations
from typing import Optional
from application.llm.openai import OpenAILLM
from application.llm.providers.base import Provider
class OpenAIProvider(Provider):
name = "openai"
llm_class = OpenAILLM
def get_api_key(self, settings) -> Optional[str]:
if settings.OPENAI_API_KEY:
return settings.OPENAI_API_KEY
if settings.LLM_PROVIDER == self.name and settings.API_KEY:
return settings.API_KEY
return None
def is_enabled(self, settings) -> bool:
# When the deployment is pointed at a custom OpenAI-compatible
# endpoint (Ollama, LM Studio, ...), the cloud-OpenAI catalog is
# suppressed but ``is_enabled`` stays True — necessary so the
# filter below still gets to drop the catalog (rather than the
# registry skipping the provider entirely and missing the rule).
if settings.OPENAI_BASE_URL:
return True
return bool(self.get_api_key(settings))
def filter_yaml_models(self, settings, models):
# Legacy local-endpoint mode hides the cloud catalog. The
# corresponding dynamic models live in OpenAICompatibleProvider.
if settings.OPENAI_BASE_URL:
return []
if not settings.OPENAI_API_KEY:
return []
return models

View File

@@ -0,0 +1,149 @@
"""Generic provider for OpenAI-wire-compatible endpoints.
Each ``openai_compatible`` YAML file describes one logical endpoint
(Mistral, Together, Fireworks, Ollama, ...) with its own
``api_key_env`` and ``base_url``. Multiple files can coexist; the
plugin produces one set of models per file, each pre-configured with
the right credentials and URL.
The plugin also handles the **legacy** ``OPENAI_BASE_URL`` + ``LLM_NAME``
local-endpoint pattern that previously lived in ``OpenAIProvider``. That
path generates models dynamically from ``LLM_NAME``, using
``OPENAI_BASE_URL`` and ``OPENAI_API_KEY`` as the endpoint config.
"""
from __future__ import annotations
import logging
import os
from typing import List, Optional
from application.core.model_settings import (
AvailableModel,
ModelCapabilities,
ModelProvider,
)
from application.llm.openai import OpenAILLM
from application.llm.providers.base import Provider
logger = logging.getLogger(__name__)
def _parse_model_names(llm_name: Optional[str]) -> List[str]:
if not llm_name:
return []
return [name.strip() for name in llm_name.split(",") if name.strip()]
class OpenAICompatibleProvider(Provider):
name = "openai_compatible"
llm_class = OpenAILLM
def get_api_key(self, settings) -> Optional[str]:
# Per-model: each catalog supplies its own ``api_key_env``. There
# is no single plugin-wide key. LLMCreator reads the per-model
# ``api_key`` set during catalog materialization.
return None
def is_enabled(self, settings) -> bool:
# Concrete enablement happens per catalog (in ``get_models``).
# Returning True lets the registry call ``get_models`` so we can
# decide per-file whether to contribute models.
return True
def get_models(self, settings, catalogs) -> List[AvailableModel]:
out: List[AvailableModel] = []
for catalog in catalogs:
out.extend(self._materialize_yaml_catalog(catalog))
if settings.OPENAI_BASE_URL and settings.LLM_NAME:
out.extend(self._materialize_legacy_local_endpoint(settings))
return out
def _materialize_yaml_catalog(self, catalog) -> List[AvailableModel]:
"""Resolve one openai_compatible YAML into ready-to-dispatch models.
Skipped (with an INFO-level log) if ``api_key_env`` resolves to
nothing — no point publishing models the user can't actually
call. INFO rather than WARNING because operators may legitimately
drop multiple provider YAMLs as templates and only set the env
vars for the ones they actually use; a missing key is ambiguous,
not necessarily a misconfig.
"""
if not catalog.base_url:
raise ValueError(
f"{catalog.source_path}: openai_compatible YAML must set "
"'base_url'."
)
if not catalog.api_key_env:
raise ValueError(
f"{catalog.source_path}: openai_compatible YAML must set "
"'api_key_env'."
)
api_key = os.environ.get(catalog.api_key_env)
if not api_key:
logger.info(
"openai_compatible catalog %s skipped: env var %s is not set",
catalog.source_path,
catalog.api_key_env,
)
return []
out: List[AvailableModel] = []
for m in catalog.models:
out.append(self._with_endpoint(m, catalog.base_url, api_key))
return out
def _materialize_legacy_local_endpoint(self, settings) -> List[AvailableModel]:
"""Generate AvailableModels from ``LLM_NAME`` for the legacy
``OPENAI_BASE_URL`` deployment pattern (Ollama, LM Studio, ...).
Preserves the historical ``provider="openai"`` display behavior
by setting ``display_provider="openai"``.
"""
from application.core.model_yaml import resolve_attachment_alias
attachments = resolve_attachment_alias("image")
api_key = settings.OPENAI_API_KEY or settings.API_KEY
out: List[AvailableModel] = []
for model_name in _parse_model_names(settings.LLM_NAME):
out.append(
AvailableModel(
id=model_name,
provider=ModelProvider.OPENAI_COMPATIBLE,
display_name=model_name,
description=f"Custom OpenAI-compatible model at {settings.OPENAI_BASE_URL}",
base_url=settings.OPENAI_BASE_URL,
capabilities=ModelCapabilities(
supports_tools=True,
supported_attachment_types=attachments,
),
api_key=api_key,
display_provider="openai",
)
)
return out
@staticmethod
def _with_endpoint(
model: AvailableModel, base_url: str, api_key: str
) -> AvailableModel:
"""Return a copy of ``model`` carrying the catalog's endpoint config.
The catalog-level ``base_url`` is the default; an explicit
per-model ``base_url`` in the YAML wins.
"""
return AvailableModel(
id=model.id,
provider=model.provider,
display_name=model.display_name,
description=model.description,
capabilities=model.capabilities,
enabled=model.enabled,
base_url=model.base_url or base_url,
display_provider=model.display_provider,
api_key=api_key,
)

View File

@@ -0,0 +1,23 @@
from __future__ import annotations
from typing import Optional
from application.llm.open_router import OpenRouterLLM
from application.llm.providers._apikey_or_llm_name import (
filter_models_by_llm_name,
get_api_key,
)
from application.llm.providers.base import Provider
class OpenRouterProvider(Provider):
name = "openrouter"
llm_class = OpenRouterLLM
def get_api_key(self, settings) -> Optional[str]:
return get_api_key(settings, self.name, settings.OPEN_ROUTER_API_KEY)
def filter_yaml_models(self, settings, models):
return filter_models_by_llm_name(
settings, self.name, settings.OPEN_ROUTER_API_KEY, models
)

View File

@@ -0,0 +1,19 @@
from __future__ import annotations
from typing import Optional
from application.llm.premai import PremAILLM
from application.llm.providers.base import Provider
class PremAIProvider(Provider):
"""LLMCreator-only plugin: invocable via LLM_PROVIDER but not in the catalog."""
name = "premai"
llm_class = PremAILLM
def get_api_key(self, settings) -> Optional[str]:
return settings.API_KEY
def is_enabled(self, settings) -> bool:
return False

View File

@@ -0,0 +1,24 @@
from __future__ import annotations
from typing import Optional
from application.llm.sagemaker import SagemakerAPILLM
from application.llm.providers.base import Provider
class SagemakerProvider(Provider):
"""LLMCreator-only plugin: invocable via LLM_PROVIDER but not in the catalog.
SageMaker reads its credentials from ``SAGEMAKER_*`` settings inside
the LLM class itself; this plugin's ``get_api_key`` exists only for
LLMCreator's symmetry.
"""
name = "sagemaker"
llm_class = SagemakerAPILLM
def get_api_key(self, settings) -> Optional[str]:
return settings.API_KEY
def is_enabled(self, settings) -> bool:
return False

59
application/mcp_server.py Normal file
View File

@@ -0,0 +1,59 @@
"""FastMCP server exposing DocsGPT retrieval over streamable HTTP.
Mounted at ``/mcp`` by ``application/asgi.py``. Bearer tokens are the
existing DocsGPT agent API keys — no new credential surface.
The tool reads the ``Authorization`` header directly via
``get_http_headers(include={"authorization"})``. The ``include`` kwarg
is required: by default ``get_http_headers`` strips ``authorization``
(and a handful of other hop-by-hop headers) so they aren't forwarded
to downstream services — since we deliberately want the caller's
token, we opt it back in.
"""
from __future__ import annotations
import asyncio
import logging
from fastmcp import FastMCP
from fastmcp.server.dependencies import get_http_headers
from application.services.search_service import (
InvalidAPIKey,
SearchFailed,
search,
)
logger = logging.getLogger(__name__)
mcp = FastMCP("docsgpt")
def _extract_bearer_token() -> str | None:
auth = get_http_headers(include={"authorization"}).get("authorization", "")
parts = auth.split(None, 1)
if len(parts) != 2 or parts[0].lower() != "bearer" or not parts[1]:
return None
return parts[1]
@mcp.tool
async def search_docs(query: str, chunks: int = 5) -> list[dict]:
"""Search the caller's DocsGPT knowledge base.
Authentication is via ``Authorization: Bearer <agent-api-key>`` on
the MCP request — the same opaque key that ``/api/search`` accepts
in its JSON body. Returns at most ``chunks`` hits, each a dict with
``text``, ``title``, ``source`` keys.
"""
api_key = _extract_bearer_token()
if not api_key:
raise PermissionError("Missing Bearer token")
try:
return await asyncio.to_thread(search, api_key, query, chunks)
except InvalidAPIKey as exc:
raise PermissionError("Invalid API key") from exc
except SearchFailed:
logger.exception("search_docs failed")
raise

View File

@@ -1,9 +1,12 @@
a2wsgi==1.10.10
alembic>=1.13,<2
anthropic==0.88.0
asgiref>=3.11.1
boto3==1.42.83
beautifulsoup4==4.14.3
cel-python==0.5.0
celery==5.6.3
celery-redbeat==2.3.3
cryptography==46.0.7
dataclasses-json==0.6.7
defusedxml==0.7.1
@@ -14,7 +17,7 @@ docx2txt==0.9
ddgs>=8.0.0
fast-ebook
elevenlabs==2.43.0
Flask==3.1.3
Flask==3.1.1
faiss-cpu==1.13.2
fastmcp==3.2.4
flask-restx==1.3.2
@@ -49,6 +52,16 @@ networkx==3.6.1
numpy==2.4.4
openai==2.32.0
openapi3-parser==1.1.22
opentelemetry-distro>=0.50b0,<1
opentelemetry-exporter-otlp>=1.29.0,<2
opentelemetry-instrumentation-celery>=0.50b0,<1
opentelemetry-instrumentation-flask>=0.50b0,<1
opentelemetry-instrumentation-logging>=0.50b0,<1
opentelemetry-instrumentation-psycopg>=0.50b0,<1
opentelemetry-instrumentation-redis>=0.50b0,<1
opentelemetry-instrumentation-requests>=0.50b0,<1
opentelemetry-instrumentation-sqlalchemy>=0.50b0,<1
opentelemetry-instrumentation-starlette>=0.50b0,<1
orjson==3.11.7
packaging==26.0
pandas==3.0.2
@@ -58,7 +71,7 @@ pdf2image>=1.17.0
pillow
portalocker>=2.7.0,<4.0.0
prompt-toolkit==3.0.52
protobuf==7.34.1
protobuf==6.33.6
psycopg[binary,pool]>=3.1,<4
py==1.11.0
pydantic
@@ -69,6 +82,7 @@ python-dateutil==2.9.0.post0
python-dotenv
python-jose==3.5.0
python-pptx==1.0.2
PyYAML
redis==7.4.0
referencing>=0.28.0,<0.38.0
regex==2026.4.4
@@ -76,6 +90,7 @@ requests==2.33.1
retry==0.9.2
sentence-transformers==5.3.0
sqlalchemy>=2.0,<3
starlette>=1.0,<2
tiktoken==0.12.0
tokenizers==0.22.2
torch==2.11.0
@@ -85,6 +100,8 @@ typing-extensions==4.15.0
typing-inspect==0.9.0
tzdata==2026.1
urllib3==2.6.3
uvicorn[standard]>=0.30,<1
uvicorn-worker>=0.4,<1
vine==5.1.0
wcwidth==0.6.0
werkzeug>=3.1.0

View File

@@ -0,0 +1,153 @@
"""Shared retrieval service used by the HTTP search route and the MCP tool.
Flask-free. Raises domain exceptions (``InvalidAPIKey``, ``SearchFailed``)
that callers translate into their own wire protocol (HTTP status codes,
MCP error responses, etc.).
"""
from __future__ import annotations
import logging
from typing import Any, Dict, List
from application.core.settings import settings
from application.storage.db.repositories.agents import AgentsRepository
from application.storage.db.session import db_readonly
from application.vectorstore.vector_creator import VectorCreator
logger = logging.getLogger(__name__)
class InvalidAPIKey(Exception):
"""The supplied ``api_key`` does not resolve to an agent."""
class SearchFailed(Exception):
"""Unexpected error during retrieval (e.g. DB outage). Caller maps to 5xx."""
def _collect_source_ids(agent: Dict[str, Any]) -> List[str]:
"""Extract the ordered list of source UUIDs to search.
Prefers ``extra_source_ids`` (PG ARRAY(UUID) of multi-source agents);
falls back to the legacy single ``source_id`` field.
"""
source_ids: List[str] = []
extra = agent.get("extra_source_ids") or []
for src in extra:
if src:
source_ids.append(str(src))
if not source_ids:
single = agent.get("source_id")
if single:
source_ids.append(str(single))
return source_ids
def _search_sources(
query: str, source_ids: List[str], chunks: int
) -> List[Dict[str, Any]]:
"""Search across each source's vectorstore and return up to ``chunks`` hits.
Per-source errors are logged and skipped so one broken index doesn't
take down the whole search. Results are de-duplicated by content hash.
"""
if chunks <= 0 or not source_ids:
return []
results: List[Dict[str, Any]] = []
chunks_per_source = max(1, chunks // len(source_ids))
seen_texts: set[int] = set()
for source_id in source_ids:
if not source_id or not source_id.strip():
continue
try:
docsearch = VectorCreator.create_vectorstore(
settings.VECTOR_STORE, source_id, settings.EMBEDDINGS_KEY
)
docs = docsearch.search(query, k=chunks_per_source * 2)
for doc in docs:
if len(results) >= chunks:
break
if hasattr(doc, "page_content") and hasattr(doc, "metadata"):
page_content = doc.page_content
metadata = doc.metadata
else:
page_content = doc.get("text", doc.get("page_content", ""))
metadata = doc.get("metadata", {})
text_hash = hash(page_content[:200])
if text_hash in seen_texts:
continue
seen_texts.add(text_hash)
title = metadata.get("title", metadata.get("post_title", ""))
if not isinstance(title, str):
title = str(title) if title else ""
if title:
title = title.split("/")[-1]
else:
title = metadata.get("filename", page_content[:50] + "...")
source = metadata.get("source", source_id)
results.append(
{
"text": page_content,
"title": title,
"source": source,
}
)
if len(results) >= chunks:
break
except Exception as e:
logger.error(
f"Error searching vectorstore {source_id}: {e}",
exc_info=True,
)
continue
return results[:chunks]
def search(api_key: str, query: str, chunks: int = 5) -> List[Dict[str, Any]]:
"""Resolve an agent by API key and search its sources.
Args:
api_key: Agent API key (the opaque string stored on
``agents.key`` in Postgres).
query: Free-text search query.
chunks: Max number of hits to return.
Returns:
List of hit dicts with ``text``, ``title``, ``source`` keys.
Empty list if the agent has no sources configured.
Raises:
InvalidAPIKey: if ``api_key`` does not resolve to an agent.
SearchFailed: on unexpected DB / infrastructure errors.
"""
if chunks <= 0:
return []
try:
with db_readonly() as conn:
agent = AgentsRepository(conn).find_by_key(api_key)
except Exception as e:
raise SearchFailed("agent lookup failed") from e
if not agent:
raise InvalidAPIKey()
source_ids = _collect_source_ids(agent)
if not source_ids:
return []
return _search_sources(query, source_ids, chunks)

View File

@@ -117,6 +117,16 @@ stack_logs_table = Table(
Column("timestamp", DateTime(timezone=True), nullable=False, server_default=func.now()),
)
# Singleton key/value table for instance-wide state (e.g. anonymous
# instance UUID, one-shot notice flags). Added in migration
# ``0002_app_metadata``.
app_metadata_table = Table(
"app_metadata",
metadata,
Column("key", Text, primary_key=True),
Column("value", Text, nullable=False),
)
# --- Phase 2, Tier 2 --------------------------------------------------------

View File

@@ -1,7 +1,6 @@
"""Repository for the ``agents`` table.
This is the most complex Phase 2 repository. Covers every write operation
the legacy Mongo code performs on ``agents_collection``:
Covers every write operation the legacy Mongo code performs on ``agents_collection``:
- create, update, delete
- find by key (API key lookup)

View File

@@ -0,0 +1,60 @@
"""Repository for the ``app_metadata`` singleton key/value table.
Owns the instance-wide state the version-check client needs:
``instance_id`` (anonymous UUID sent with each check) and
``version_check_notice_shown`` (one-shot flag for the first-run
telemetry notice). Kept deliberately generic so future one-off config
values can piggyback without a new migration each time.
"""
from __future__ import annotations
import uuid
from typing import Optional
from sqlalchemy import Connection, text
class AppMetadataRepository:
"""Postgres-backed ``app_metadata`` store. Tiny by design."""
def __init__(self, conn: Connection) -> None:
self._conn = conn
def get(self, key: str) -> Optional[str]:
row = self._conn.execute(
text("SELECT value FROM app_metadata WHERE key = :key"),
{"key": key},
).fetchone()
return row[0] if row is not None else None
def set(self, key: str, value: str) -> None:
self._conn.execute(
text(
"INSERT INTO app_metadata (key, value) VALUES (:key, :value) "
"ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value"
),
{"key": key, "value": value},
)
def get_or_create_instance_id(self) -> str:
"""Return the anonymous instance UUID, generating one if absent.
Uses ``INSERT ... ON CONFLICT DO NOTHING`` + re-read so two
workers racing on the very first startup converge on a single
UUID instead of each persisting their own.
"""
existing = self.get("instance_id")
if existing:
return existing
candidate = str(uuid.uuid4())
self._conn.execute(
text(
"INSERT INTO app_metadata (key, value) VALUES ('instance_id', :value) "
"ON CONFLICT (key) DO NOTHING"
),
{"value": candidate},
)
# Re-read: if another worker won the race, their UUID is now authoritative.
winner = self.get("instance_id")
return winner or candidate

View File

View File

@@ -0,0 +1,304 @@
"""Anonymous version-check client.
Fired on every Celery worker boot (see ``application/celery_init.py``
``worker_ready`` handler) and on a 7h periodic schedule (see the
``version-check`` entry in ``application/api/user/tasks.py``). Posts
the running version + anonymous instance UUID to
``gptcloud.arc53.com/api/check``, caches the response in Redis, and
surfaces any advisories to stdout + logs.
Design invariants — all enforced by a broad ``try/except`` at the top
of :func:`run_check`:
* Never blocks worker startup (fired from a daemon thread).
* Never raises to the caller (every failure is swallowed + logged at
``DEBUG``).
* Opt-out via ``VERSION_CHECK=0`` short-circuits before any Postgres
write, Redis access, or outbound request.
* Redis coordinates multi-worker and multi-replica deployments — the
first worker to acquire ``docsgpt:version_check:lock`` fetches, the
rest read from the cached response on the next cycle.
"""
from __future__ import annotations
import json
import logging
import os
import platform
import socket
import sys
from typing import Any, Dict, Optional
import requests
from application.cache import get_redis_instance
from application.core.settings import settings
from application.storage.db.repositories.app_metadata import AppMetadataRepository
from application.storage.db.session import db_session
from application.version import get_version
logger = logging.getLogger(__name__)
ENDPOINT_URL = "https://gptcloud.arc53.com/api/check"
CLIENT_NAME = "docsgpt-backend"
REQUEST_TIMEOUT_SECONDS = 5
CACHE_KEY = "docsgpt:version_check:response"
LOCK_KEY = "docsgpt:version_check:lock"
CACHE_TTL_SECONDS = 6 * 3600 # 6h default; shortened by response `next_check_after`.
LOCK_TTL_SECONDS = 60
NOTICE_KEY = "version_check_notice_shown"
INSTANCE_ID_KEY = "instance_id"
_HIGH_SEVERITIES = {"high", "critical"}
_ANSI_RESET = "\033[0m"
_ANSI_RED = "\033[31m"
_ANSI_YELLOW = "\033[33m"
def run_check() -> None:
"""Entry point for the worker-startup daemon thread.
Safe to call unconditionally: the opt-out, Redis-outage, and
Postgres-outage paths all return silently. No exception propagates.
"""
try:
_run_check_inner()
except Exception as exc: # noqa: BLE001 — belt-and-braces; nothing escapes.
logger.debug("version check crashed: %s", exc, exc_info=True)
def _run_check_inner() -> None:
if not settings.VERSION_CHECK:
return
instance_id = _resolve_instance_id_and_notice()
if instance_id is None:
# Postgres unavailable — per spec we skip the check entirely
# rather than phone home with a synthetic/ephemeral UUID.
return
redis_client = get_redis_instance()
cached = _read_cache(redis_client)
if cached is not None:
_render_advisories(cached)
return
# Cache miss. Try to win the lock; if another worker has it, skip.
# ``redis_client is None`` here means Redis is unreachable — per the
# spec we still proceed uncached (acceptable duplicate calls in
# multi-worker Redis-less deploys).
if redis_client is not None and not _acquire_lock(redis_client):
return
response = _fetch(instance_id)
if response is None:
if redis_client is not None:
_release_lock(redis_client)
return
_write_cache(redis_client, response)
_render_advisories(response)
if redis_client is not None:
_release_lock(redis_client)
def _resolve_instance_id_and_notice() -> Optional[str]:
"""Load (or create) the instance UUID and emit the first-run notice.
The notice is printed at most once across the lifetime of the
installation — tracked via the ``version_check_notice_shown`` row
in ``app_metadata``. Both reads and the write happen inside one
short transaction so two racing workers can't each emit the notice.
"""
try:
with db_session() as conn:
repo = AppMetadataRepository(conn)
instance_id = repo.get_or_create_instance_id()
if repo.get(NOTICE_KEY) is None:
_print_first_run_notice()
repo.set(NOTICE_KEY, "1")
return instance_id
except Exception as exc: # noqa: BLE001 — Postgres down, bad URI, etc.
logger.debug("version check: Postgres unavailable (%s)", exc, exc_info=True)
return None
def _print_first_run_notice() -> None:
message = (
"Anonymous version check enabled — sends version to "
"gptcloud.arc53.com.\nDisable with VERSION_CHECK=0."
)
print(message, flush=True)
logger.info("version check: first-run notice shown")
def _read_cache(redis_client) -> Optional[Dict[str, Any]]:
if redis_client is None:
return None
try:
raw = redis_client.get(CACHE_KEY)
except Exception as exc: # noqa: BLE001 — Redis transient errors.
logger.debug("version check: cache GET failed (%s)", exc, exc_info=True)
return None
if raw is None:
return None
try:
return json.loads(raw.decode("utf-8") if isinstance(raw, bytes) else raw)
except (ValueError, AttributeError) as exc:
logger.debug("version check: cache decode failed (%s)", exc, exc_info=True)
return None
def _write_cache(redis_client, response: Dict[str, Any]) -> None:
if redis_client is None:
return
ttl = _compute_ttl(response)
try:
redis_client.setex(CACHE_KEY, ttl, json.dumps(response))
except Exception as exc: # noqa: BLE001
logger.debug("version check: cache SETEX failed (%s)", exc, exc_info=True)
def _compute_ttl(response: Dict[str, Any]) -> int:
"""Cap the cache at 6h but honor a shorter server-specified window."""
next_after = response.get("next_check_after")
if isinstance(next_after, (int, float)) and next_after > 0:
return max(1, min(CACHE_TTL_SECONDS, int(next_after)))
return CACHE_TTL_SECONDS
def _acquire_lock(redis_client) -> bool:
try:
owner = f"{socket.gethostname()}:{os.getpid()}"
return bool(
redis_client.set(LOCK_KEY, owner, nx=True, ex=LOCK_TTL_SECONDS)
)
except Exception as exc: # noqa: BLE001
# Treat a failing Redis the same as "no lock infra" — skip rather
# than fire without coordination, because Redis outage is
# usually transient and one missed cycle is harmless.
logger.debug("version check: lock acquire failed (%s)", exc, exc_info=True)
return False
def _release_lock(redis_client) -> None:
try:
redis_client.delete(LOCK_KEY)
except Exception as exc: # noqa: BLE001
logger.debug("version check: lock release failed (%s)", exc, exc_info=True)
def _fetch(instance_id: str) -> Optional[Dict[str, Any]]:
version = get_version()
if version in ("", "unknown"):
# The endpoint rejects payloads without a valid semver, and the
# rejection is otherwise logged at DEBUG — invisible under the
# usual ``-l INFO`` Celery worker start. Surface it loudly so a
# misconfigured release (missing or unset ``__version__``) is
# obvious instead of silently disabling the check.
logger.warning(
"version check: skipping — get_version() returned %r. "
"Set __version__ in application/version.py to a valid "
"version string.",
version,
)
return None
payload = {
"version": version,
"instance_id": instance_id,
"python_version": platform.python_version(),
"platform": sys.platform,
"client": CLIENT_NAME,
}
try:
resp = requests.post(
ENDPOINT_URL,
json=payload,
timeout=REQUEST_TIMEOUT_SECONDS,
)
except requests.RequestException as exc:
logger.debug("version check: request failed (%s)", exc, exc_info=True)
return None
if resp.status_code >= 400:
logger.debug("version check: non-2xx response %s", resp.status_code)
return None
try:
return resp.json()
except ValueError as exc:
logger.debug("version check: response decode failed (%s)", exc, exc_info=True)
return None
def _render_advisories(response: Dict[str, Any]) -> None:
advisories = response.get("advisories") or []
if not isinstance(advisories, list):
return
current_version = get_version()
for advisory in advisories:
if not isinstance(advisory, dict):
continue
severity = str(advisory.get("severity", "")).lower()
advisory_id = advisory.get("id", "UNKNOWN")
title = advisory.get("title", "")
url = advisory.get("url", "")
fixed_in = advisory.get("fixed_in")
summary = advisory.get(
"summary",
f"Your DocsGPT version {current_version} is vulnerable.",
)
logger.warning(
"security advisory %s (severity=%s) affects version %s: %s%s%s",
advisory_id,
severity or "unknown",
current_version,
title or summary,
f" — fixed in {fixed_in}" if fixed_in else "",
f"{url}" if url else "",
)
if severity in _HIGH_SEVERITIES:
_print_console_advisory(
advisory_id=advisory_id,
title=title,
severity=severity,
summary=summary,
fixed_in=fixed_in,
url=url,
)
def _print_console_advisory(
*,
advisory_id: str,
title: str,
severity: str,
summary: str,
fixed_in: Optional[str],
url: str,
) -> None:
color = _ANSI_RED if severity == "critical" else _ANSI_YELLOW
bar = "=" * 60
upgrade_line = ""
if fixed_in and url:
upgrade_line = f" Upgrade to {fixed_in}+ — {url}"
elif fixed_in:
upgrade_line = f" Upgrade to {fixed_in}+"
elif url:
upgrade_line = f" {url}"
lines = [
bar,
f"\u26a0 SECURITY ADVISORY: {advisory_id}",
f" {summary}",
f" {title} (severity: {severity})" if title else f" severity: {severity}",
]
if upgrade_line:
lines.append(upgrade_line)
lines.append(bar)
print(f"{color}{chr(10).join(lines)}{_ANSI_RESET}", flush=True)

10
application/version.py Normal file
View File

@@ -0,0 +1,10 @@
"""DocsGPT backend version string."""
from __future__ import annotations
__version__ = "0.17.0"
def get_version() -> str:
"""Return the DocsGPT backend version."""
return __version__

View File

@@ -348,6 +348,16 @@ def run_agent_logic(agent_config, input_data):
model_id = agent_default_model
else:
model_id = get_default_model_id()
if agent_default_model:
# Stored model_id no longer resolves in the registry. Log so
# operators can detect bad YAML edits before users complain;
# behavior matches the historical silent fallback.
logging.warning(
"Agent %s references unknown model_id %r; falling back to %r",
agent_id,
agent_default_model,
model_id,
)
# Get provider and API key for the selected model
provider = get_provider_from_model_id(model_id) if model_id else settings.LLM_PROVIDER

View File

@@ -104,7 +104,15 @@ To run the DocsGPT backend locally, you'll need to set up a Python environment a
flask --app application/app.py run --host=0.0.0.0 --port=7091
```
This command will launch the backend server, making it accessible on `http://localhost:7091`.
This command will launch the backend server, making it accessible on `http://localhost:7091`. It's the fastest inner-loop option for day-to-day development — the Werkzeug interactive debugger still works and it hot-reloads on source changes. It serves the Flask routes only.
If you need to exercise the full ASGI stack — the `/mcp` endpoint (FastMCP server), or to match the production runtime — run the ASGI composition under uvicorn instead:
```bash
uvicorn application.asgi:asgi_app --host 0.0.0.0 --port 7091 --reload
```
Production uses `gunicorn -k uvicorn_worker.UvicornWorker` against the same `application.asgi:asgi_app` target.
6. **Start the Celery Worker:**

View File

@@ -99,6 +99,82 @@ EMBEDDINGS_NAME=huggingface_sentence-transformers/all-mpnet-base-v2 # You can al
In this case, even though you are using Ollama locally, `LLM_PROVIDER` is set to `openai` because Ollama (and many other local inference engines) are designed to be API-compatible with OpenAI. `OPENAI_BASE_URL` points DocsGPT to the local Ollama server.
## Adding Custom Models (`MODELS_CONFIG_DIR`)
DocsGPT ships with a built-in catalog of models for the providers it
supports out of the box (OpenAI, Anthropic, Google, Groq, OpenRouter,
Novita, Azure OpenAI, Hugging Face, DocsGPT). To add **your own
models** without forking the repo — for example, a Mistral or Together
account, a self-hosted vLLM endpoint, or any other OpenAI-compatible
API — point `MODELS_CONFIG_DIR` at a directory of YAML files.
```
MODELS_CONFIG_DIR=/etc/docsgpt/models
MISTRAL_API_KEY=sk-...
```
A minimal YAML for one provider:
```yaml
# /etc/docsgpt/models/mistral.yaml
provider: openai_compatible
display_provider: mistral
api_key_env: MISTRAL_API_KEY
base_url: https://api.mistral.ai/v1
defaults:
supports_tools: true
context_window: 128000
models:
- id: mistral-large-latest
display_name: Mistral Large
- id: mistral-small-latest
display_name: Mistral Small
```
After restart, those models appear in `/api/models` and are selectable
in the UI. A working template lives at
`application/core/models/examples/mistral.yaml.example`.
**What you can do:**
- Add new `openai_compatible` providers (Mistral, Together, Fireworks,
Ollama, vLLM, ...) — one YAML per provider, each with its own
`api_key_env` and `base_url`.
- Extend an existing provider's catalog by dropping a YAML with the
same `provider:` value as the built-in (e.g. `provider: anthropic`
with extra models).
- Override a built-in model's capabilities by re-declaring the same
`id` — later wins, override is logged at `WARNING`.
**What you cannot do via `MODELS_CONFIG_DIR`:** add a brand-new
non-OpenAI provider. That requires a Python plugin under
`application/llm/providers/`. See
`application/core/models/README.md` for the full schema reference.
### Docker
Mount the directory and set the env var:
```yaml
# docker-compose.yml
services:
app:
image: arc53/docsgpt
environment:
MODELS_CONFIG_DIR: /etc/docsgpt/models
MISTRAL_API_KEY: ${MISTRAL_API_KEY}
volumes:
- ./my-models:/etc/docsgpt/models:ro
```
### Misconfiguration
If `MODELS_CONFIG_DIR` is set but the path doesn't exist (or isn't a
directory), the app logs a `WARNING` at boot and continues with just
the built-in catalog — it does **not** fail to start. If a YAML
declares an unknown provider name or has a schema error, the app
**does** fail to start, with the offending file path in the message.
## Speech-to-Text Settings
DocsGPT can transcribe audio in two places:

View File

@@ -0,0 +1,111 @@
---
title: Observability
description: Send traces, metrics, and logs from DocsGPT to any OpenTelemetry-compatible backend (Axiom, Honeycomb, Grafana, Datadog, Jaeger, etc.).
---
import { Callout } from 'nextra/components'
# Observability
DocsGPT bundles the OpenTelemetry SDK and auto-instrumentation packages
in `application/requirements.txt` — they install with the rest of the
backend deps. Telemetry is **off by default**; opt in by prefixing the
launch command with `opentelemetry-instrument` and setting OTLP env
vars.
Auto-instrumentation covers Flask, Starlette, Celery, SQLAlchemy,
psycopg, Redis, requests, and Python logging. LLM/retriever calls are
not captured at this layer — see *Going further* below.
## Enabling
Set these env vars in your `.env` (or compose `environment:` block):
```bash
OTEL_SDK_DISABLED=false
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_EXPORTER_OTLP_ENDPOINT=https://your-collector.example.com
OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer%20<token>
OTEL_TRACES_EXPORTER=otlp
OTEL_METRICS_EXPORTER=otlp
OTEL_LOGS_EXPORTER=otlp
OTEL_PYTHON_LOG_CORRELATION=true
OTEL_RESOURCE_ATTRIBUTES=service.name=docsgpt-backend,deployment.environment=prod
```
Then prefix the process command with `opentelemetry-instrument`. The
simplest way is a compose override (no image rebuild):
```yaml
# deployment/docker-compose.override.yaml
services:
backend:
command: >
opentelemetry-instrument gunicorn -w 1 -k uvicorn_worker.UvicornWorker
--bind 0.0.0.0:7091 --config application/gunicorn_conf.py
application.asgi:asgi_app
environment:
- OTEL_SERVICE_NAME=docsgpt-backend
worker:
command: opentelemetry-instrument celery -A application.app.celery worker -l INFO -B
environment:
- OTEL_SERVICE_NAME=docsgpt-celery-worker
```
For local dev, prepend `dotenv run --` so the `OTEL_*` vars from `.env`
reach `opentelemetry-instrument` before it boots the SDK:
```bash
dotenv run -- opentelemetry-instrument flask --app application/app.py run --port=7091
dotenv run -- opentelemetry-instrument celery -A application.app.celery worker -l INFO --pool=solo
```
<Callout type="info" emoji="">
Logs are exported in-process when `OTEL_LOGS_EXPORTER=otlp` is set —
`application/core/logging_config.py` detects the flag and preserves
the OTEL log handler. Without it, `logging` writes only to stdout.
</Callout>
## Backend examples
### Axiom
```bash
OTEL_EXPORTER_OTLP_ENDPOINT=https://api.axiom.co
OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer%20xaat-XXXX,X-Axiom-Dataset=docsgpt
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
```
`%20` is the URL-encoded space between `Bearer` and the token. Create
the dataset in the Axiom UI before sending.
### Self-hosted OTLP collector / Jaeger / Tempo
```bash
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
OTEL_EXPORTER_OTLP_PROTOCOL=grpc
```
### Honeycomb / Grafana Cloud / Datadog
Each vendor publishes a single-line `OTEL_EXPORTER_OTLP_ENDPOINT` plus
`OTEL_EXPORTER_OTLP_HEADERS` recipe — drop them in alongside the
service-name override.
## Caveats
- The Dockerfile uses `gunicorn -w 1`. If you raise worker count, move
SDK init into a `post_worker_init` hook to avoid one-thread-per-process
exporter contention.
- `asgi.py` wraps Flask in Starlette's `WSGIMiddleware`. Both
instrumentors are installed, so each request produces a Starlette
span enclosing a Flask span. Drop
`opentelemetry-instrumentation-flask` from `requirements.txt` if the
duplication is noisy.
- OTEL packages add ~50 MB to the image. They install on every build —
the runtime cost is zero unless you set `opentelemetry-instrument` on
the command and set the OTLP env vars.
- The OTEL exporter ecosystem currently caps `protobuf` at `<7`, so the
backend runs on protobuf 6.x. This will catch up in a future OTEL
release.

View File

@@ -23,6 +23,10 @@ export default {
"title": "🐘 PostgreSQL for User Data",
"href": "/Deploying/Postgres-Migration"
},
"Observability": {
"title": "🔭 Observability",
"href": "/Deploying/Observability"
},
"Amazon-Lightsail": {
"title": "Hosting DocsGPT on Amazon Lightsail",
"href": "/Deploying/Amazon-Lightsail",

View File

@@ -1,38 +0,0 @@
---
title: Add DocsGPT Chrome Extension to Your Browser
description: Install the DocsGPT Chrome extension to access AI-powered document assistance directly from your browser for enhanced productivity.
---
import {Steps} from 'nextra/components'
import { Callout } from 'nextra/components'
## Chrome Extension Setup Guide
To enhance your DocsGPT experience, you can install the DocsGPT Chrome extension. Here's how:
<Steps >
### Step 1
In the DocsGPT GitHub repository, click on the **Code** button and select **Download ZIP**.
### Step 2
Unzip the downloaded file to a location you can easily access.
### Step 3
Open the Google Chrome browser and click on the three dots menu (upper right corner).
### Step 4
Select **More Tools** and then **Extensions**.
### Step 5
Turn on the **Developer mode** switch in the top right corner of the **Extensions page**.
### Step 6
Click on the **Load unpacked** button.
### Step 7
7. Select the **Chrome** folder where the DocsGPT files have been unzipped (docsgpt-main > extensions > chrome).
### Step 8
The extension should now be added to Google Chrome and can be managed on the Extensions page.
### Step 9
To disable or remove the extension, simply turn off the toggle switch on the extension card or click the **Remove** button.
</Steps>

View File

@@ -11,10 +11,6 @@ export default {
"title": "🔎 Search Widget",
"href": "/Extensions/search-widget"
},
"Chrome-extension": {
"title": "🌐 Chrome Extension",
"href": "/Extensions/Chrome-extension"
},
"Chatwoot-extension": {
"title": "🗣️ Chatwoot Extension",
"href": "/Extensions/Chatwoot-extension"

View File

@@ -1,6 +1,7 @@
export default {
"index": "Home",
"quickstart": "Quickstart",
"upgrading": "Upgrading",
"Deploying": "Deploying",
"Models": "Models",
"Tools": "Tools",

View File

@@ -0,0 +1,66 @@
---
title: Upgrading DocsGPT
description: Upgrade your DocsGPT deployment across Docker Compose, source builds, and Kubernetes.
---
import { Callout } from 'nextra/components'
# Upgrading DocsGPT
<Callout type="warning">
**Upgrading from 0.16.x?** User data moved from MongoDB to Postgres in 0.17.0. Follow the [Postgres Migration guide](/Deploying/Postgres-Migration) before running `docker compose pull` or `git pull` — existing deployments will not start cleanly without it.
</Callout>
## Check your version
```bash
docker compose exec backend python -c "from application.version import get_version; print(get_version())"
```
Release notes: [changelog](/changelog). Tags: [GitHub releases](https://github.com/arc53/DocsGPT/releases).
## Docker Compose — hub images
```bash
cd DocsGPT/deployment
docker compose -f docker-compose-hub.yaml pull
docker compose -f docker-compose-hub.yaml up -d
```
`pull` fetches the latest image for whichever tag your compose file references. To move to a specific release, edit `image: arc53/docsgpt:<tag>` first.
## Docker Compose — from source
```bash
cd DocsGPT
git pull
docker compose -f deployment/docker-compose.yaml build
docker compose -f deployment/docker-compose.yaml up -d
```
Swap `git pull` for `git checkout <tag>` if you want to pin a specific release.
## Kubernetes
```bash
kubectl set image deployment/docsgpt-backend backend=arc53/docsgpt:<tag>
kubectl set image deployment/docsgpt-worker worker=arc53/docsgpt:<tag>
kubectl rollout status deployment/docsgpt-backend
kubectl rollout status deployment/docsgpt-worker
```
Full manifests: [Kubernetes deployment guide](/Deploying/Kubernetes-Deploying).
## Migrations
Alembic migrations run on worker startup. To apply manually:
```bash
docker compose exec backend alembic -c application/alembic.ini upgrade head
```
`upgrade head` is idempotent.
## Rollback
Set the image tag to the previous release and `up -d` again. Schema changes are not reversible without a backup — take one before upgrading any release that mentions migrations in the changelog.

View File

@@ -1,66 +0,0 @@
{
"l10nTabName": {
"message":"Localization"
,"description":"name of the localization tab"
}
,"l10nHeader": {
"message":"It does localization too! (this whole tab is, actually)"
,"description":"Header text for the localization section"
}
,"l10nIntro": {
"message":"'L10n' refers to 'Localization' - 'L' an 'n' are obvious, and 10 comes from the number of letters between those two. It is the process/whatever of displaying something in the language of choice. It uses 'I18n', 'Internationalization', which refers to the tools / framework supporting L10n. I.e., something is internationalized if it has I18n support, and can be localized. Something is localized for you if it is in your language / dialect."
,"description":"introduce the basic idea."
}
,"l10nProd": {
"message":"You <strong>are</strong> planning to allow localization, right? You have <em>no idea</em> who will be using your extension! You have no idea who will be translating it! At least support the basics, it's not hard, and having the framework in place will let you transition much more easily later on."
,"description":"drive the point home. It's good for you."
}
,"l10nFirstParagraph": {
"message":"When the options page loads, elements decorated with <strong>data-l10n</strong> will automatically be localized!"
,"description":"inform that <el data-l10n='' /> elements will be localized on load"
}
,"l10nSecondParagraph": {
"message":"If you need more complex localization, you can also define <strong>data-l10n-args</strong>. This should contain <span class='code'>$containerType$</span> filled with <span class='code'>$dataType$</span>, which will be passed into Chrome's i18n API as <span class='code'>$functionArgs$</span>. In fact, this paragraph does just that, and wraps the args in mono-space font. Easy!"
,"description":"introduce the data-l10n-args attribute. End on a lame note."
,"placeholders": {
"containerType": {
"content":"$1"
,"example":"'array', 'list', or something similar"
,"description":"type of the args container"
}
,"dataType": {
"content":"$2"
,"example":"string"
,"description":"type of data in each array index"
}
,"functionArgs": {
"content":"$3"
,"example":"arguments"
,"description":"whatever you call what you pass into a function/method. args, params, etc."
}
}
}
,"l10nThirdParagraph": {
"message":"Message contents are passed right into innerHTML without processing - include any tags (or even scripts) that you feel like. If you have an input field, the placeholder will be set instead, and buttons will have the value attribute set."
,"description":"inform that we handle placeholders, buttons, and direct HTML input"
}
,"l10nButtonsBefore": {
"message":"Different types of buttons are handled as well. &lt;button&gt; elements have their html set:"
}
,"l10nButton": {
"message":"in a <strong>button</strong>"
}
,"l10nButtonsBetween": {
"message":"while &lt;input type='submit'&gt; and &lt;input type='button'&gt; get their 'value' set (note: no HTML):"
}
,"l10nSubmit": {
"message":"a <strong>submit</strong> value"
}
,"l10nButtonsAfter": {
"message":"Awesome, no?"
}
,"l10nExtras": {
"message":"You can even set <span class='code'>data-l10n</span> on things like the &lt;title&gt; tag, which lets you have translatable page titles, or fieldset &lt;legend&gt; tags, or anywhere else - the default <span class='code'>Boil.localize()</span> behavior will check every tag in the document, not just the body."
,"description":"inform about places which may not be obvious, like <title>, etc"
}
}

View File

@@ -1,678 +0,0 @@
/*
! tailwindcss v3.2.7 | MIT License | https://tailwindcss.com
*/
/*
1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
*/
*,
::before,
::after {
box-sizing: border-box;
/* 1 */
border-width: 0;
/* 2 */
border-style: solid;
/* 2 */
border-color: #e5e7eb;
/* 2 */
}
::before,
::after {
--tw-content: '';
}
/*
1. Use a consistent sensible line-height in all browsers.
2. Prevent adjustments of font size after orientation changes in iOS.
3. Use a more readable tab size.
4. Use the user's configured `sans` font-family by default.
5. Use the user's configured `sans` font-feature-settings by default.
*/
html {
line-height: 1.5;
/* 1 */
-webkit-text-size-adjust: 100%;
/* 2 */
-moz-tab-size: 4;
/* 3 */
-o-tab-size: 4;
tab-size: 4;
/* 3 */
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
/* 4 */
font-feature-settings: normal;
/* 5 */
}
/*
1. Remove the margin in all browsers.
2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
*/
body {
margin: 0;
/* 1 */
line-height: inherit;
/* 2 */
}
/*
1. Add the correct height in Firefox.
2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
3. Ensure horizontal rules are visible by default.
*/
hr {
height: 0;
/* 1 */
color: inherit;
/* 2 */
border-top-width: 1px;
/* 3 */
}
/*
Add the correct text decoration in Chrome, Edge, and Safari.
*/
abbr:where([title]) {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
}
/*
Remove the default font size and weight for headings.
*/
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: inherit;
font-weight: inherit;
}
/*
Reset links to optimize for opt-in styling instead of opt-out.
*/
a {
color: inherit;
text-decoration: inherit;
}
/*
Add the correct font weight in Edge and Safari.
*/
b,
strong {
font-weight: bolder;
}
/*
1. Use the user's configured `mono` font family by default.
2. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
samp,
pre {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
/* 1 */
font-size: 1em;
/* 2 */
}
/*
Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/*
Prevent `sub` and `sup` elements from affecting the line height in all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/*
1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
3. Remove gaps between table borders by default.
*/
table {
text-indent: 0;
/* 1 */
border-color: inherit;
/* 2 */
border-collapse: collapse;
/* 3 */
}
/*
1. Change the font styles in all browsers.
2. Remove the margin in Firefox and Safari.
3. Remove default padding in all browsers.
*/
button,
input,
optgroup,
select,
textarea {
font-family: inherit;
/* 1 */
font-size: 100%;
/* 1 */
font-weight: inherit;
/* 1 */
line-height: inherit;
/* 1 */
color: inherit;
/* 1 */
margin: 0;
/* 2 */
padding: 0;
/* 3 */
}
/*
Remove the inheritance of text transform in Edge and Firefox.
*/
button,
select {
text-transform: none;
}
/*
1. Correct the inability to style clickable types in iOS and Safari.
2. Remove default button styles.
*/
button,
[type='button'],
[type='reset'],
[type='submit'] {
-webkit-appearance: button;
/* 1 */
background-color: transparent;
/* 2 */
background-image: none;
/* 2 */
}
/*
Use the modern Firefox focus style for all focusable elements.
*/
:-moz-focusring {
outline: auto;
}
/*
Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
*/
:-moz-ui-invalid {
box-shadow: none;
}
/*
Add the correct vertical alignment in Chrome and Firefox.
*/
progress {
vertical-align: baseline;
}
/*
Correct the cursor style of increment and decrement buttons in Safari.
*/
::-webkit-inner-spin-button,
::-webkit-outer-spin-button {
height: auto;
}
/*
1. Correct the odd appearance in Chrome and Safari.
2. Correct the outline style in Safari.
*/
[type='search'] {
-webkit-appearance: textfield;
/* 1 */
outline-offset: -2px;
/* 2 */
}
/*
Remove the inner padding in Chrome and Safari on macOS.
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
/*
1. Correct the inability to style clickable types in iOS and Safari.
2. Change font properties to `inherit` in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button;
/* 1 */
font: inherit;
/* 2 */
}
/*
Add the correct display in Chrome and Safari.
*/
summary {
display: list-item;
}
/*
Removes the default spacing and border for appropriate elements.
*/
blockquote,
dl,
dd,
h1,
h2,
h3,
h4,
h5,
h6,
hr,
figure,
p,
pre {
margin: 0;
}
fieldset {
margin: 0;
padding: 0;
}
legend {
padding: 0;
}
ol,
ul,
menu {
list-style: none;
margin: 0;
padding: 0;
}
/*
Prevent resizing textareas horizontally by default.
*/
textarea {
resize: vertical;
}
/*
1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
2. Set the default placeholder color to the user's configured gray 400 color.
*/
input::-moz-placeholder, textarea::-moz-placeholder {
opacity: 1;
/* 1 */
color: #9ca3af;
/* 2 */
}
input::placeholder,
textarea::placeholder {
opacity: 1;
/* 1 */
color: #9ca3af;
/* 2 */
}
/*
Set the default cursor for buttons.
*/
button,
[role="button"] {
cursor: pointer;
}
/*
Make sure disabled buttons don't get the pointer cursor.
*/
:disabled {
cursor: default;
}
/*
1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
This can trigger a poorly considered lint error in some tools but is included by design.
*/
img,
svg,
video,
canvas,
audio,
iframe,
embed,
object {
display: block;
/* 1 */
vertical-align: middle;
/* 2 */
}
/*
Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
*/
img,
video {
max-width: 100%;
height: auto;
}
/* Make elements with the HTML hidden attribute stay hidden by default */
[hidden] {
display: none;
}
*, ::before, ::after {
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
--tw-numeric-spacing: ;
--tw-numeric-fraction: ;
--tw-ring-inset: ;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: rgb(59 130 246 / 0.5);
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-shadow-colored: 0 0 #0000;
--tw-blur: ;
--tw-brightness: ;
--tw-contrast: ;
--tw-grayscale: ;
--tw-hue-rotate: ;
--tw-invert: ;
--tw-saturate: ;
--tw-sepia: ;
--tw-drop-shadow: ;
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia: ;
}
::backdrop {
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
--tw-numeric-spacing: ;
--tw-numeric-fraction: ;
--tw-ring-inset: ;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: rgb(59 130 246 / 0.5);
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-shadow-colored: 0 0 #0000;
--tw-blur: ;
--tw-brightness: ;
--tw-contrast: ;
--tw-grayscale: ;
--tw-hue-rotate: ;
--tw-invert: ;
--tw-saturate: ;
--tw-sepia: ;
--tw-drop-shadow: ;
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia: ;
}
.mb-2 {
margin-bottom: 0.5rem;
}
.ml-2 {
margin-left: 0.5rem;
}
.mr-2 {
margin-right: 0.5rem;
}
.mt-4 {
margin-top: 1rem;
}
.flex {
display: flex;
}
.w-\[26rem\] {
width: 26rem;
}
.w-full {
width: 100%;
}
.flex-col {
flex-direction: column;
}
.items-center {
align-items: center;
}
.justify-between {
justify-content: space-between;
}
.self-start {
align-self: flex-start;
}
.self-end {
align-self: flex-end;
}
.rounded-lg {
border-radius: 0.5rem;
}
.bg-blue-500 {
--tw-bg-opacity: 1;
background-color: rgb(59 130 246 / var(--tw-bg-opacity));
}
.bg-gray-200 {
--tw-bg-opacity: 1;
background-color: rgb(229 231 235 / var(--tw-bg-opacity));
}
.bg-gray-900 {
--tw-bg-opacity: 1;
background-color: rgb(17 24 39 / var(--tw-bg-opacity));
}
.bg-indigo-500 {
--tw-bg-opacity: 1;
background-color: rgb(99 102 241 / var(--tw-bg-opacity));
}
.bg-white {
--tw-bg-opacity: 1;
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
}
.p-2 {
padding: 0.5rem;
}
.p-4 {
padding: 1rem;
}
.text-lg {
font-size: 1.125rem;
line-height: 1.75rem;
}
.text-sm {
font-size: 0.875rem;
line-height: 1.25rem;
}
.font-medium {
font-weight: 500;
}
.text-blue-500 {
--tw-text-opacity: 1;
color: rgb(59 130 246 / var(--tw-text-opacity));
}
.text-gray-700 {
--tw-text-opacity: 1;
color: rgb(55 65 81 / var(--tw-text-opacity));
}
.text-white {
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.shadow {
--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
}
#chat-container {
width: 500px;
height: 450px;
background-color: white;
padding: 10px;
overflow: auto;
}
.bg-gray-200 {
background-color: #edf2f7;
}
.bg-gray-900 {
background-color: #1a202c;
}
.rounded-lg {
border-radius: 0.5rem;
}
.shadow {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
}
.text-gray-700 {
color: #4a5568;
}
.text-sm {
font-size: 0.875rem;
}
.p-4 {
padding: 1.5rem;
}
.hover\:text-blue-800:hover {
--tw-text-opacity: 1;
color: rgb(30 64 175 / var(--tw-text-opacity));
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 509 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 789 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 749 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 930 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -1,3 +0,0 @@
build
jquery-migrate.js
jquery-migrate.min.js

View File

@@ -1,4 +0,0 @@
jQuery Component
================
Shim repository for jQuery.

View File

@@ -1,13 +0,0 @@
{
"name": "jquery",
"version": "2.0.0",
"description": "jQuery component",
"keywords": [
"jquery",
"component"
],
"scripts": [
"jquery.js"
],
"license": "MIT"
}

View File

@@ -1,21 +0,0 @@
{
"name": "jquery",
"version": "2.0.0",
"description": "jQuery component",
"keywords": [
"jquery",
"component"
],
"scripts": [
"jquery.js"
],
"license": "MIT",
"gitHead": "46f8412bd1bb9b1b30b5b0eb88560e2d4196509c",
"readme": "jQuery Component\n================\n\nShim repository for jQuery.\n",
"readmeFilename": "README.md",
"_id": "jquery@2.0.0",
"repository": {
"type": "git",
"url": "git://github.com/components/jquery.git"
}
}

View File

@@ -1,30 +0,0 @@
{
"name": "components/jquery",
"description": "jQuery JavaScript Library",
"type": "component",
"homepage": "http://jquery.com",
"license": "MIT",
"support": {
"irc": "irc://irc.freenode.org/jquery",
"issues": "http://bugs.jquery.com",
"forum": "http://forum.jquery.com",
"wiki": "http://docs.jquery.com/",
"source": "https://github.com/jquery/jquery"
},
"authors": [
{
"name": "John Resig",
"email": "jeresig@gmail.com"
}
],
"require": {
"robloach/component-installer": "*"
},
"extra": {
"component": {
"scripts": [
"jquery.js"
]
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +0,0 @@
{
"name": "components-jquery",
"version": "2.0.0",
"description": "jQuery component",
"keywords": ["jquery"],
"main": "./jquery.js"
}

View File

@@ -1,28 +0,0 @@
{
"name": "DocsGPT - Documentation AI butler",
"version": "0.0.1",
"manifest_version": 3,
"description": "AI assistant for developers, that helps you answer your questions about the documentation you are reading.",
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"default_locale": "en",
"background": {
"service_worker": "src/bg/service-worker.js"
},
"action": {
"default_title": "DocsGPT - Documentation AI butler",
"default_popup": "popup.html"
},
"permissions": ["activeTab", "storage"],
"host_permissions": [
"*://*/*"
],
"content_scripts": [{
"js": ["popup.js"],
"matches": ["https://github.com/*"]
}]
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +0,0 @@
{
"name": "docsgpt-chrome-extension",
"version": "0.0.1",
"description": "DocsGPT - Documentation AI butler",
"main": "popup.js",
"author": "",
"license": "MIT",
"scripts": {
"dev": "npx tailwindcss -i ./styles.css -o ./dist/output.css --watch"
},
"keywords": [
"DocsGPT",
"Documentation",
"Chrome",
"extension"
],
"devDependencies": {
"tailwindcss": "^3.2.4"
}
}

View File

@@ -1,56 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Chat Extension</title>
<link href="/dist/output.css" rel="stylesheet">
<!-- <link rel="stylesheet" href="styles.css"> -->
</head>
<body>
<header class="bg-white p-2 flex justify-between items-center">
<h1 class="text-lg font-medium">DocsGPT</h1>
<a href="#about" class="text-blue-500 hover:text-blue-800">About</a>
</header>
<div class="w-full flex flex-col">
<div id="chat-container">
<div id="messages" class="w-full flex flex-col">
<div class="bg-indigo-500 text-white p-2 rounded-lg mb-2 self-start">
<p class="text-sm">Hello, ask me anything about this library. Im here to help</p>
</div>
<div class="bg-purple-30 text-white p-2 rounded-lg mb-2 self-end">
<p class="text-sm">How to create API key for Api gateway?</p>
</div>
<div class="bg-indigo-500 text-white p-2 rounded-lg mb-2 self-start">
<p class="text-sm">Import the boto3 library and create a client for the API Gateway service:</p>
</div>
<div class="bg-indigo-500 text-white p-2 rounded-lg mb-2 self-start">
<code class="text-sm">client = boto3.client('apigateway')</code>
</div>
<div class="bg-indigo-500 text-white p-2 rounded-lg mb-2 self-start">
<p class="text-sm">Create an API key:</p>
</div>
<div class="bg-indigo-500 text-white p-2 rounded-lg mb-2 self-start">
<code class="text-sm">response = client.create_api_key(<br>name='API_KEY_NAME',<br>description='API key description',<br>enabled=True)<br>api_key = response['value']</code>
</div>
</div>
</div>
<div class=" flex mt-4 mb-2">
<form id="message-form">
<input id="message-input" class="bg-white p-2 rounded-lg ml-2 w-[26rem]" type="text" placeholder="Type your message here...">
<button class="bg-purple-30 text-white p-2 rounded-lg ml-2 mr-2 ml-2" type="submit">Send</button>
</form>
</div>
</div>
<script src="popup.js"></script>
</body>
</html>

View File

@@ -1,46 +0,0 @@
document.getElementById("message-form").addEventListener("submit", function(event) {
event.preventDefault();
var message = document.getElementById("message-input").value;
chrome.runtime.sendMessage({msg: "sendMessage", message: message}, function(response) {
console.log(response.response);
msg_html = '<div class="bg-purple-30 text-white p-2 rounded-lg mb-2 self-end"><p class="text-sm">'
msg_html += message
msg_html += '</p></div>'
document.getElementById("messages").innerHTML += msg_html;
let chatWindow = document.getElementById("chat-container");
chatWindow.scrollTop = chatWindow.scrollHeight;
});
document.getElementById("message-input").value = "";
var conversation_state = localStorage.getItem("conversation_state");
// check if conversation state is null
if (conversation_state == null) {
conversation_state = 0;
localStorage.setItem("conversation_state", conversation_state);
}
// send post request to server http://127.0.0.1:5000/ with message in json body
fetch('http://127.0.0.1:7091/api/answer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({question: message, history: null}),
})
.then(response => response.json())
.then(data => {
console.log('Success:', data);
msg_html = '<div class="bg-indigo-500 text-white p-2 rounded-lg mb-2 self-start"><code class="text-sm">'
msg_html += data.answer
msg_html += '</code></div>'
document.getElementById("messages").innerHTML += msg_html;
let chatWindow = document.getElementById("chat-container");
chatWindow.scrollTop = chatWindow.scrollHeight;
})
});

View File

@@ -1,12 +0,0 @@
// This is the service worker script, which executes in its own context
// when the extension is installed or refreshed (or when you access its console).
// It would correspond to the background script in chrome extensions v2.
console.log("This prints to the console of the service worker (background script)");
chrome.runtime.onMessage.addListener(
function(request, sender, sendResponse) {
if (request.msg === "sendMessage") {
sendResponse({response: "Message received"});
}
}
);

View File

@@ -1,45 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
#chat-container {
width: 500px;
height: 450px;
background-color: white;
padding: 10px;
overflow: auto;
}
.bg-gray-200 {
background-color: #edf2f7;
}
.bg-gray-900 {
background-color: #1a202c;
}
.rounded-lg {
border-radius: 0.5rem;
}
.shadow {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
}
.text-gray-700 {
color: #4a5568;
}
.text-sm {
font-size: 0.875rem;
}
.p-4 {
padding: 1.5rem;
}

View File

@@ -1,7 +0,0 @@
module.exports = {
content: ["./src/**/*.{html,js}", "./*.{html,js,css}"],
theme: {
extend: {},
},
plugins: [],
}

View File

@@ -1,158 +0,0 @@
import os
import re
import logging
import aiohttp
import discord
from discord.ext import commands
import dotenv
dotenv.load_dotenv()
# Enable logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Bot configuration
TOKEN = os.getenv("DISCORD_TOKEN")
PREFIX = '!' # Command prefix
BASE_API_URL = os.getenv("API_BASE", "https://gptcloud.arc53.com")
API_URL = BASE_API_URL + "/api/answer"
API_KEY = os.getenv("API_KEY")
intents = discord.Intents.default()
intents.message_content = True
bot = commands.Bot(command_prefix=PREFIX, intents=intents)
# Store conversation history per user
conversation_histories = {}
def chunk_string(text, max_length=2000):
"""Splits a string into chunks of a specified maximum length."""
# Create list to store the split strings
chunks = []
# Loop through the text, create substrings with max_length
while len(text) > max_length:
# Find last space within the limit
idx = text.rfind(' ', 0, max_length)
# Ensure we don't have an empty part
if idx == -1:
# If no spaces, just take chunk
chunks.append(text[:max_length])
text = text[max_length:]
else:
# Push whatever we've got up to the last space
chunks.append(text[:idx])
text = text[idx+1:]
# Catches the remaining part
chunks.append(text)
return chunks
def escape_markdown(text):
"""Escapes Discord markdown characters."""
escape_chars = r'\*_$$$$()~>#+-=|{}.!'
return re.sub(f'([{re.escape(escape_chars)}])', r'\\\1', text)
def split_string(input_str):
"""Splits the input string to detect bot mentions."""
pattern = r'^<@!?{0}>\s*'.format(bot.user.id)
match = re.match(pattern, input_str)
if match:
content = input_str[match.end():].strip()
return str(bot.user.id), content
return None, input_str
@bot.event
async def on_ready():
print(f'{bot.user.name} has connected to Discord!')
async def generate_answer(question, messages, conversation_id):
"""Generates an answer using the external API."""
payload = {
"question": question,
"api_key": API_KEY,
"history": messages,
"conversation_id": conversation_id
}
headers = {
"Content-Type": "application/json; charset=utf-8"
}
timeout = aiohttp.ClientTimeout(total=60)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(API_URL, json=payload, headers=headers) as resp:
if resp.status == 200:
data = await resp.json()
conversation_id = data.get("conversation_id")
answer = data.get("answer", "Sorry, I couldn't find an answer.")
return {"answer": answer, "conversation_id": conversation_id}
else:
return {"answer": "Sorry, I couldn't find an answer.", "conversation_id": None}
@bot.command(name="start")
async def start(ctx):
"""Handles the /start command."""
await ctx.send(f"Hi {ctx.author.mention}! How can I assist you today?")
@bot.command(name="custom_help")
async def custom_help_command(ctx):
"""Handles the /custom_help command."""
help_text = (
"Here are the available commands:\n"
"`!start` - Begin a new conversation with the bot\n"
"`!help` - Display this help message\n\n"
"You can also mention me or send a direct message to ask a question!"
)
await ctx.send(help_text)
@bot.event
async def on_message(message):
if message.author == bot.user:
return
# Process commands first
await bot.process_commands(message)
# Check if the message is in a DM channel
if isinstance(message.channel, discord.DMChannel):
content = message.content.strip()
else:
# In guild channels, check if the message mentions the bot at the start
content = message.content.strip()
prefix, content = split_string(content)
if prefix is None:
return
part_prefix = str(bot.user.id)
if part_prefix != prefix:
return # Bot not mentioned at the start, so do not process
# Now process the message
user_id = message.author.id
if user_id not in conversation_histories:
conversation_histories[user_id] = {
"history": [],
"conversation_id": None
}
conversation = conversation_histories[user_id]
conversation["history"].append({"prompt": content})
# Generate the answer
response_doc = await generate_answer(
content,
conversation["history"],
conversation["conversation_id"]
)
answer = response_doc["answer"]
conversation_id = response_doc["conversation_id"]
answer_chunks = chunk_string(answer)
for chunk in answer_chunks:
await message.channel.send(chunk)
conversation["history"][-1]["response"] = answer
conversation["conversation_id"] = conversation_id
# Keep conversation history to last 10 exchanges
conversation["history"] = conversation["history"][-10:]
bot.run(TOKEN)

View File

@@ -36,8 +36,8 @@
"@types/markdown-it": "^14.1.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.57.2",
"@typescript-eslint/parser": "^8.57.2",
"@typescript-eslint/eslint-plugin": "^8.59.0",
"@typescript-eslint/parser": "^8.59.0",
"babel-loader": "^10.1.1",
"eslint": "^9.39.4",
"eslint-config-prettier": "^10.1.8",
@@ -49,7 +49,7 @@
"prettier": "^3.8.1",
"process": "^0.11.10",
"svgo": "^4.0.1",
"typescript": "^5.3.3"
"typescript": "^6.0.3"
}
},
"node_modules/@babel/code-frame": {
@@ -499,11 +499,12 @@
}
},
"node_modules/@babel/plugin-syntax-flow": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.24.6.tgz",
"integrity": "sha512-gNkksSdV8RbsCoHF9sjVYrHfYACMl/8U32UfUhJ9+84/ASXw8dlx+eHyyF0m6ncQJ9IBSxfuCkB36GJqYdXTOA==",
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.28.6.tgz",
"integrity": "sha512-D+OrJumc9McXNEBI/JmFnc/0uCM2/Y3PEBG3gfV3QIYkKv5pvnpzFrl1kYCrcHJP8nOeFB/SHi1IHz29pNGuew==",
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.24.6"
"@babel/helper-plugin-utils": "^7.28.6"
},
"engines": {
"node": ">=6.9.0"
@@ -864,12 +865,13 @@
}
},
"node_modules/@babel/plugin-transform-flow-strip-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.24.6.tgz",
"integrity": "sha512-1l8b24NoCpaQ13Vi6FtLG1nv6kNoi8PWvQb1AYO7GHZDpFfBYc3lbXArx1lP2KRt8b4pej1eWc/zrRmsQTfOdQ==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.27.1.tgz",
"integrity": "sha512-G5eDKsu50udECw7DL2AcsysXiQyB7Nfg521t2OAJ4tbfTJ27doHLeF/vlI1NZGlLdbb/v+ibvtL1YBQqYOwJGg==",
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.24.6",
"@babel/plugin-syntax-flow": "^7.24.6"
"@babel/helper-plugin-utils": "^7.27.1",
"@babel/plugin-syntax-flow": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -4538,20 +4540,20 @@
"devOptional": true
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz",
"integrity": "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==",
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz",
"integrity": "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.57.2",
"@typescript-eslint/type-utils": "8.57.2",
"@typescript-eslint/utils": "8.57.2",
"@typescript-eslint/visitor-keys": "8.57.2",
"@typescript-eslint/scope-manager": "8.59.0",
"@typescript-eslint/type-utils": "8.59.0",
"@typescript-eslint/utils": "8.59.0",
"@typescript-eslint/visitor-keys": "8.59.0",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.4.0"
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4561,22 +4563,22 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.57.2",
"@typescript-eslint/parser": "^8.59.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz",
"integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==",
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.0.tgz",
"integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.57.2",
"@typescript-eslint/types": "8.57.2",
"@typescript-eslint/typescript-estree": "8.57.2",
"@typescript-eslint/visitor-keys": "8.57.2",
"@typescript-eslint/scope-manager": "8.59.0",
"@typescript-eslint/types": "8.59.0",
"@typescript-eslint/typescript-estree": "8.59.0",
"@typescript-eslint/visitor-keys": "8.59.0",
"debug": "^4.4.3"
},
"engines": {
@@ -4588,18 +4590,18 @@
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz",
"integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==",
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.0.tgz",
"integrity": "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.57.2",
"@typescript-eslint/types": "^8.57.2",
"@typescript-eslint/tsconfig-utils": "^8.59.0",
"@typescript-eslint/types": "^8.59.0",
"debug": "^4.4.3"
},
"engines": {
@@ -4610,18 +4612,18 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.0.0"
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz",
"integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==",
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.0.tgz",
"integrity": "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.57.2",
"@typescript-eslint/visitor-keys": "8.57.2"
"@typescript-eslint/types": "8.59.0",
"@typescript-eslint/visitor-keys": "8.59.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4632,9 +4634,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz",
"integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==",
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.0.tgz",
"integrity": "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -4645,21 +4647,21 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.0.0"
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz",
"integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==",
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.0.tgz",
"integrity": "sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.57.2",
"@typescript-eslint/typescript-estree": "8.57.2",
"@typescript-eslint/utils": "8.57.2",
"@typescript-eslint/types": "8.59.0",
"@typescript-eslint/typescript-estree": "8.59.0",
"@typescript-eslint/utils": "8.59.0",
"debug": "^4.4.3",
"ts-api-utils": "^2.4.0"
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4670,13 +4672,13 @@
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz",
"integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==",
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.0.tgz",
"integrity": "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -4688,21 +4690,21 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz",
"integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==",
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.0.tgz",
"integrity": "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.57.2",
"@typescript-eslint/tsconfig-utils": "8.57.2",
"@typescript-eslint/types": "8.57.2",
"@typescript-eslint/visitor-keys": "8.57.2",
"@typescript-eslint/project-service": "8.59.0",
"@typescript-eslint/tsconfig-utils": "8.59.0",
"@typescript-eslint/types": "8.59.0",
"@typescript-eslint/visitor-keys": "8.59.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.4.0"
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4712,7 +4714,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.0.0"
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
@@ -4729,16 +4731,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz",
"integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==",
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.0.tgz",
"integrity": "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.57.2",
"@typescript-eslint/types": "8.57.2",
"@typescript-eslint/typescript-estree": "8.57.2"
"@typescript-eslint/scope-manager": "8.59.0",
"@typescript-eslint/types": "8.59.0",
"@typescript-eslint/typescript-estree": "8.59.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4749,17 +4751,17 @@
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz",
"integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==",
"version": "8.59.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.0.tgz",
"integrity": "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.57.2",
"@typescript-eslint/types": "8.59.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -7804,13 +7806,13 @@
}
},
"node_modules/minimatch": {
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.2"
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
@@ -8330,9 +8332,9 @@
}
},
"node_modules/prettier": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"version": "3.8.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
"dev": true,
"license": "MIT",
"bin": {
@@ -9095,14 +9097,14 @@
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
@@ -9246,9 +9248,9 @@
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",

View File

@@ -69,8 +69,8 @@
"@types/markdown-it": "^14.1.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.57.2",
"@typescript-eslint/parser": "^8.57.2",
"@typescript-eslint/eslint-plugin": "^8.59.0",
"@typescript-eslint/parser": "^8.59.0",
"babel-loader": "^10.1.1",
"eslint": "^9.39.4",
"eslint-config-prettier": "^10.1.8",
@@ -82,7 +82,7 @@
"prettier": "^3.8.1",
"process": "^0.11.10",
"svgo": "^4.0.1",
"typescript": "^5.3.3"
"typescript": "^6.0.3"
},
"publishConfig": {
"access": "public"

View File

@@ -1,3 +0,0 @@
.env
.venv/
get-pip.py

View File

@@ -1,84 +0,0 @@
# Slack Bot Configuration Guide
> **Note:** The following guidelines must be followed on the [Slack API website](https://api.slack.com/) for setting up your Slack app and generating the necessary tokens.
## Step-by-Step Instructions
### 1. Navigate to Your Apps
- Go to the Slack API page for apps and select **Create an App** from the “From Scratch” option.
### 2. App Creation
- Name your app and choose the workspace where you wish to add the assistant.
### 3. Enabling Socket Mode
- Navigate to **Settings > Socket Mode** and enable **Socket Mode**.
- This action will generate an App-level token. Select the `connections:write` scope and copy the App-level token for future use.
### 4. Socket Naming
- Assign a name to your socket as per your preference.
### 5. Basic Information Setup
- Go to **Basic Information** (under **Settings**) and configure the following:
- Assistant name
- App icon
- Background color
### 6. Bot Token and Permissions
- In the **OAuth & Permissions** option found under the **Features** section, retrieve the Bot Token. Save it for future usage.
- You will also need to add specific bot token scopes:
- `app_mentions:read`
- `assistant:write`
- `chat:write`
- `chat:write.public`
- `im:history`
### 7. Enable Events
- From **Event Subscriptions**, enable events and add the following Bot User events:
- `app_mention`
- `assistant_thread_context_changed`
- `assistant_thread_started`
- `message.im`
### 8. Agent/Assistant Toggle
- In the **Features > Agent & Assistants** section, toggle on the Agent or Assistant option.
- In the **Suggested Prompts** setting, leave it as `dynamic` (this is the default setting).
---
## Code-Side Configuration Guide
This section focuses on generating and setting up the necessary tokens in the `.env` file, using the `.env-example` as a template.
### Step 1: Generating Required Keys
1. **SLACK_APP_TOKEN**
- Navigate to **Settings > Socket Mode** in the Slack API and enable **Socket Mode**.
- Copy the App-level token generated (usually starts with `xapp-`).
2. **SLACK_BOT_TOKEN**
- Go to **OAuth & Permissions** (under the **Features** section in Slack API).
- Retrieve the **Bot Token** (starts with `xoxb-`).
3. **DOCSGPT_API_KEY**
- Go to the **DocsGPT website**.
- Navigate to **Settings > Chatbots > Create New** to generate a DocsGPT API Key.
- Copy the generated key for use.
### Step 2: Creating and Updating the `.env` File
1. Create a new `.env` file in the root of your project (if it doesnt already exist).
2. Use the `.env-example` as a reference and update the file with the following keys and values:
```bash
# .env file
SLACK_APP_TOKEN=xapp-your-generated-app-token
SLACK_BOT_TOKEN=xoxb-your-generated-bot-token
DOCSGPT_API_KEY=your-docsgpt-generated-api-key
```
Replace the placeholder values with the actual tokens generated from the Slack API and DocsGPT as per the steps outlined above.
---
This concludes the guide for both setting up the Slack API and configuring the `.env` file on the code side.

View File

@@ -1,112 +0,0 @@
import os
import hashlib
import httpx
import re
from slack_bolt.async_app import AsyncApp
from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler
from dotenv import load_dotenv
load_dotenv()
API_BASE = os.getenv("API_BASE", "https://gptcloud.arc53.com")
API_URL = API_BASE + "/api/answer"
# Slack bot token and signing secret
SLACK_BOT_TOKEN = os.getenv("SLACK_BOT_TOKEN")
SLACK_APP_TOKEN = os.getenv("SLACK_APP_TOKEN")
# OpenAI API key for DocsGPT (replace this with your actual API key)
DOCSGPT_API_KEY = os.getenv("DOCSGPT_API_KEY")
# Initialize Slack app
app = AsyncApp(token=SLACK_BOT_TOKEN)
def encode_conversation_id(conversation_id: str) -> str:
"""
Encodes 11 length Slack conversation_id to 12 length string
Args:
conversation_id (str): The 11 digit slack conversation_id.
Returns:
str: Hashed id.
"""
# Create a SHA-256 hash of the string
hashed_id = hashlib.sha256(conversation_id.encode()).hexdigest()
# Take the first 24 characters of the hash
hashed_24_char_id = hashed_id[:24]
return hashed_24_char_id
async def generate_answer(question: str, messages: list, conversation_id: str | None) -> dict:
"""Generates an answer using the external API."""
payload = {
"question": question,
"api_key": DOCSGPT_API_KEY,
"history": messages,
"conversation_id": conversation_id,
}
headers = {
"Content-Type": "application/json; charset=utf-8"
}
timeout = 60.0
async with httpx.AsyncClient() as client:
response = await client.post(API_URL, json=payload, headers=headers, timeout=timeout)
if response.status_code == 200:
data = response.json()
conversation_id = data.get("conversation_id")
answer = data.get("answer", "Sorry, I couldn't find an answer.")
return {"answer": answer, "conversation_id": conversation_id}
else:
print(response.json())
return {"answer": "Sorry, I couldn't find an answer.", "conversation_id": None}
@app.message(".*")
async def message_docs(message, say):
client = app.client
channel = message['channel']
thread_ts = message['thread_ts']
user_query = message['text']
await client.assistant_threads_setStatus(
channel_id = channel,
thread_ts = thread_ts,
status = "is generating your answer...",
)
docs_gpt_channel_id = encode_conversation_id(thread_ts)
# Get response from DocsGPT
response = await generate_answer(user_query,[], docs_gpt_channel_id)
answer = convert_to_slack_markdown(response['answer'])
# Respond in Slack
await client.chat_postMessage(text = answer, mrkdwn= True, channel= message['channel'],
thread_ts = message['thread_ts'],)
def convert_to_slack_markdown(markdown_text: str):
# Convert bold **text** to *text* for Slack
slack_text = re.sub(r'\*\*(.*?)\*\*', r'*\1*', markdown_text) # **text** to *text*
# Convert italics _text_ to _text_ for Slack
slack_text = re.sub(r'_(.*?)_', r'_\1_', slack_text) # _text_ to _text_
# Convert inline code `code` to `code` (Slack supports backticks for inline code)
slack_text = re.sub(r'`(.*?)`', r'`\1`', slack_text)
# Convert bullet points with single or no spaces to filled bullets (•)
slack_text = re.sub(r'^\s{0,1}[-*]\s+', '', slack_text, flags=re.MULTILINE)
# Convert bullet points with multiple spaces to hollow bullets (◦)
slack_text = re.sub(r'^\s{2,}[-*]\s+', '\t', slack_text, flags=re.MULTILINE)
# Convert headers (##) to bold in Slack
slack_text = re.sub(r'^\s*#{1,6}\s*(.*?)$', r'*\1*', slack_text, flags=re.MULTILINE)
return slack_text
async def main():
handler = AsyncSocketModeHandler(app, os.environ["SLACK_APP_TOKEN"])
await handler.start_async()
# Start the app
if __name__ == "__main__":
import asyncio
asyncio.run(main())

View File

@@ -1,9 +0,0 @@
aiohttp>=3,<4
certifi==2024.7.4
h11==0.14.0
httpcore==1.0.5
httpx==0.27.0
idna==3.7
python-dotenv==1.0.1
sniffio==1.3.1
slack-bolt==1.21.0

Some files were not shown because too many files have changed in this diff Show More