Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/badges/coverage.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"schemaVersion":1,"label":"coverage","message":"73.96%","color":"red"}
{"schemaVersion":1,"label":"coverage","message":"71.21%","color":"red"}
4 changes: 2 additions & 2 deletions .github/workflows/build_and_deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
name: Build and push OpenGateRAG image
runs-on: ubuntu-latest
env:
API_IMAGE_NAME: ghcr.io/opengaterag
API_IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/opengaterag
IMAGE_TAG: ${{ github.event_name == 'release' && github.event.release.tag_name || 'latest' }}
outputs:
commit_title: ${{ steps.get_head_commit_title.outputs.title }}
Expand Down Expand Up @@ -52,7 +52,7 @@ jobs:
needs: build-opengaterag
uses: ./.github/workflows/trivy-scan.yml
with:
image-name: ghcr.io/opengaterag
image-name: ghcr.io/${{ github.repository_owner }}/opengaterag
image-tag: ${{ github.event_name == 'release' && github.event.release.tag_name || 'latest' }}

# deploy-dev:
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
```

> [!NOTE]
> The database setup by `OPENGATELLM_DATABASE_URL` environment variable must be the same as the database used by the API specified in `OPENGATELLM_URL` environment variable.
> The database setup by `OPENGATELLM_DATABASE_URL` environment variable must be the same
> as the database used by the API specified in `OPENGATELLM_URL` environment variable.

* Run API locally

Expand Down
2 changes: 1 addition & 1 deletion compose.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: opengatellm

services:
rag:
image: ghcr.io/opengaterag:latest
image: ghcr.io/etalab-ia/opengaterag:latest
restart: always
env_file: .env
environment:
Expand Down
1 change: 1 addition & 0 deletions config.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ dependencies:
url: ${OPENGATELLM_URL:-http://localhost:8000}
model_name: ${OPENGATELLM_MODEL_NAME:-openweight-embedding}
model_vector_size: ${OPENGATELLM_MODEL_VECTOR_SIZE:-1024}
# openapi_url: /openapi.json

postgres:
url: ${OPENGATELLM_DATABASE_URL:-postgresql+asyncpg://postgres:changeme@localhost:5432/postgres}
Expand Down
13 changes: 11 additions & 2 deletions opengaterag/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,14 @@ def create_app(
contact=configuration.settings.swagger_contact,
license_info=configuration.settings.swagger_license_info,
openapi_tags=configuration.settings.swagger_openapi_tags,
docs_url=configuration.settings.swagger_docs_url,
redoc_url=configuration.settings.swagger_redoc_url,
docs_url=None,
redoc_url=None,
openapi_url=None,
lifespan=None if skip_lifespan else lifespan,
)
_register_routers(app, configuration)
_setup_monitoring(app, configuration)
_setup_unified_docs(app, configuration)

return app

Expand All @@ -66,6 +68,13 @@ def metrics() -> Response:
return Response(content=data, media_type=prometheus_client.CONTENT_TYPE_LATEST)


def _setup_unified_docs(app: FastAPI, configuration: Configuration) -> None:
from opengaterag.docs.app import create_docs_app

docs_app = create_docs_app(parent_app=app, configuration=configuration)
app.mount(path="/", app=docs_app)


def _register_routers(app: FastAPI, configuration: Configuration) -> None:
disabled_routers = set(configuration.settings.disabled_routers)
hidden_routers = set(configuration.settings.hidden_routers)
Expand Down
1 change: 1 addition & 0 deletions opengaterag/api/utils/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ class OpengateLLMDependency(ConfigBaseModel):
url: constr(strip_whitespace=True, min_length=1) = Field(..., description="URL of the OpengateLLM API.", examples=["https://opengatellm.com"]) # fmt: off
model_name: str = Field(..., description="Model of the vector store to be used to embed the text in the vector store.", examples=["text-embedding-3-small"]) # fmt: off
model_vector_size: int = Field(ge=1, description="Size of the vector to be used to embed the text in the vector store.", examples=[1536]) # fmt: off
openapi_url: str = Field(default="/openapi.json", description="Path, relative to `url`, where OpengateLLM exposes its OpenAPI schema. Used to build the unified API documentation.", examples=["/openapi.json"]) # fmt: off


@custom_validation_error()
Expand Down
Empty file added opengaterag/docs/__init__.py
Empty file.
70 changes: 70 additions & 0 deletions opengaterag/docs/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import time

from fastapi import FastAPI
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
from fastapi.responses import HTMLResponse, JSONResponse
import httpx

from opengaterag.api.utils.configuration import Configuration
from opengaterag.api.utils.logging import init_logger
from opengaterag.docs.openapi import fetch_openapi_schema, merge_openapi_schemas

logger = init_logger(name=__name__)

OPENAPI_CACHE_TTL_SECONDS = 300


def create_docs_app(parent_app: FastAPI, configuration: Configuration) -> FastAPI:
"""
Build a lightweight FastAPI sub-application that serves a single, unified Swagger UI and ReDoc.

It merges the OpenAPI schema of the host application (`parent_app`, OpenGateRAG) with the schema of
the remote OpenGateLLM service (fetched from its `url`). If OpenGateLLM is unreachable, only the
OpenGateRAG schema is served so the documentation never fully breaks.
"""
settings = configuration.settings
opengatellm_url = configuration.dependencies.opengatellm.url.rstrip("/")
opengatellm_openapi_path = configuration.dependencies.opengatellm.openapi_url.lstrip("/")
opengatellm_openapi_url = f"{opengatellm_url}/{opengatellm_openapi_path}"

docs_app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
cache: dict[str, object] = {"schema": None, "expires_at": 0.0}

async def build_merged_schema() -> dict:
now = time.monotonic()
if cache["schema"] is not None and now < cache["expires_at"]:
return cache["schema"]

base_schema = parent_app.openapi()
try:
secondary_schema = await fetch_openapi_schema(url=opengatellm_openapi_url)
merged = merge_openapi_schemas(
base=base_schema,
secondary=secondary_schema,
secondary_server_url=opengatellm_url,
)
except httpx.HTTPError as error:
logger.warning(f"Unable to fetch OpenGateLLM OpenAPI schema from {opengatellm_openapi_url}: {error}")
merged = base_schema

cache["schema"] = merged
cache["expires_at"] = now + OPENAPI_CACHE_TTL_SECONDS
return merged

@docs_app.get(path=settings.swagger_openapi_url, include_in_schema=False)
async def openapi() -> JSONResponse:
return JSONResponse(content=await build_merged_schema())

@docs_app.get(path=settings.swagger_docs_url, include_in_schema=False)
async def swagger_ui() -> HTMLResponse:
return get_swagger_ui_html(
openapi_url=settings.swagger_openapi_url,
title=f"{settings.app_title} - Swagger UI",
swagger_ui_parameters={"tagsSorter": "alpha"},
)

@docs_app.get(path=settings.swagger_redoc_url, include_in_schema=False)
async def redoc() -> HTMLResponse:
return get_redoc_html(openapi_url=settings.swagger_openapi_url, title=f"{settings.app_title} - ReDoc")

return docs_app
67 changes: 67 additions & 0 deletions opengaterag/docs/openapi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import copy

import httpx


def merge_openapi_schemas(
base: dict,
secondary: dict,
secondary_server_url: str,
secondary_path_prefix: str = "",
) -> dict:
"""
Merge a `secondary` OpenAPI schema into a `base` one and return the result.

- `secondary` paths are served by another origin, so a per-operation `servers` entry pointing to
`secondary_server_url` is added (unless the operation already declares one).
- Component schemas keep their original names; on a name collision the `base` definition is kept.
- Tags of both schemas keep their original names (operations sharing a tag name are grouped together)
and the resulting top-level `tags` list is sorted alphabetically so Swagger UI and ReDoc display the
routers in alphabetical order.
"""
merged = copy.deepcopy(base)
secondary = copy.deepcopy(secondary)

merged.setdefault("paths", {})
for path, path_item in secondary.get("paths", {}).items():
for operation in path_item.values():
if not isinstance(operation, dict):
continue
operation.setdefault("servers", [{"url": secondary_server_url}])
merged["paths"][f"{secondary_path_prefix}{path}"] = path_item

merged_components = merged.setdefault("components", {})
for section, values in secondary.get("components", {}).items():
if isinstance(values, dict):
target = merged_components.setdefault(section, {})
for name, definition in values.items():
target.setdefault(name, definition)

merged["tags"] = _sorted_tags(merged, secondary)

return merged


def _sorted_tags(merged: dict, secondary: dict) -> list[dict]:
"""Build a de-duplicated, alphabetically sorted top-level `tags` list from both schemas."""
tag_objects: dict[str, dict] = {}
for tag in merged.get("tags", []) + secondary.get("tags", []):
name = tag.get("name")
if name is not None:
tag_objects.setdefault(name, tag)

for path_item in merged.get("paths", {}).values():
for operation in path_item.values():
if isinstance(operation, dict):
for name in operation.get("tags", []):
tag_objects.setdefault(name, {"name": name})

return sorted(tag_objects.values(), key=lambda tag: tag["name"].lower())


async def fetch_openapi_schema(url: str, timeout: float = 10.0) -> dict:
"""Fetch a remote OpenAPI schema. Raises `httpx.HTTPError` if the service is unreachable or errors."""
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.get(url)
response.raise_for_status()
return response.json()
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ license = { text = "MIT" }
dependencies = [
"uvicorn==0.41.0",
"fastapi==0.135.1",
"httpx==0.28.1",
"sentry-sdk[fastapi]==2.53.0",
"prometheus-fastapi-instrumentator==7.1.0",
"langchain-text-splitters==1.1.1",
"pyyaml==6.0.3",
"pydantic-settings==2.13.1",
"pydantic==2.13.4",
"pydantic-settings==2.14.2",
"sqlalchemy-utils==0.42.1",
"sqlalchemy[asyncio]==2.0.47",
"elasticsearch[async]==9.3.0",
Expand Down
Loading