From f459ffaf04e03d6eac72f2c8e71d3aa8b71b1823 Mon Sep 17 00:00:00 2001 From: leoguillaume Date: Wed, 8 Jul 2026 12:45:30 +0200 Subject: [PATCH 1/3] fix build --- .github/workflows/build_and_deploy.yml | 4 ++-- README.md | 3 ++- compose.example.yml | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build_and_deploy.yml b/.github/workflows/build_and_deploy.yml index ff77f4a..04bee42 100644 --- a/.github/workflows/build_and_deploy.yml +++ b/.github/workflows/build_and_deploy.yml @@ -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 }} @@ -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: diff --git a/README.md b/README.md index 8d77d3e..98968d3 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/compose.example.yml b/compose.example.yml index 83bee7e..1b28e55 100644 --- a/compose.example.yml +++ b/compose.example.yml @@ -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: From 4bbc66e85c874f6d5a69d39d1721e0341a4e48d7 Mon Sep 17 00:00:00 2001 From: leoguillaume Date: Wed, 8 Jul 2026 13:12:36 +0200 Subject: [PATCH 2/3] add unified openapi.json --- config.example.yml | 1 + opengaterag/api/app.py | 13 ++++- opengaterag/api/utils/configuration.py | 1 + opengaterag/docs/__init__.py | 0 opengaterag/docs/app.py | 70 ++++++++++++++++++++++++++ opengaterag/docs/openapi.py | 67 ++++++++++++++++++++++++ pyproject.toml | 4 +- 7 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 opengaterag/docs/__init__.py create mode 100644 opengaterag/docs/app.py create mode 100644 opengaterag/docs/openapi.py diff --git a/config.example.yml b/config.example.yml index 7dce461..3c9ccb6 100644 --- a/config.example.yml +++ b/config.example.yml @@ -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} diff --git a/opengaterag/api/app.py b/opengaterag/api/app.py index 76fa06f..4632dbb 100644 --- a/opengaterag/api/app.py +++ b/opengaterag/api/app.py @@ -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 @@ -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) diff --git a/opengaterag/api/utils/configuration.py b/opengaterag/api/utils/configuration.py index 8d4a985..cbf97b6 100644 --- a/opengaterag/api/utils/configuration.py +++ b/opengaterag/api/utils/configuration.py @@ -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() diff --git a/opengaterag/docs/__init__.py b/opengaterag/docs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/opengaterag/docs/app.py b/opengaterag/docs/app.py new file mode 100644 index 0000000..3866fce --- /dev/null +++ b/opengaterag/docs/app.py @@ -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 diff --git a/opengaterag/docs/openapi.py b/opengaterag/docs/openapi.py new file mode 100644 index 0000000..a436643 --- /dev/null +++ b/opengaterag/docs/openapi.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml index 287e55f..0a36f92 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", From e711fcd970803eb617d1c758ad7ab04e9922b2b3 Mon Sep 17 00:00:00 2001 From: leoguillaume Date: Wed, 8 Jul 2026 11:13:37 +0000 Subject: [PATCH 3/3] Update unit coverage badge --- .github/badges/coverage.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/badges/coverage.json b/.github/badges/coverage.json index 5f17e9b..8272f5e 100644 --- a/.github/badges/coverage.json +++ b/.github/badges/coverage.json @@ -1 +1 @@ -{"schemaVersion":1,"label":"coverage","message":"73.96%","color":"red"} +{"schemaVersion":1,"label":"coverage","message":"71.21%","color":"red"}