Livrare LOT 1 - Didi

This commit is contained in:
Dezvoltari Evotech 2026-06-25 14:13:25 -07:00
commit 5380c3fc63
990 changed files with 133308 additions and 0 deletions

View file

@ -0,0 +1,225 @@
# Catalog API Documentation
Service catalog and discovery API that aggregates component information from all ML services.
## Base URL
```
{BASE_URL}
```
- **Docker (internal):** `http://didiAI-catalog-api:11000`
- **Via gateway:** `http://<host>:11000/catalog/`
## Authentication
No authentication required on direct access. When accessed via the gateway, Bearer token authentication is enforced by nginx.
---
## Endpoints
### Health Check
```
GET /health
```
**Response:**
```json
{
"status": "ok"
}
```
---
### List Components
List all registered components with full metadata (resource info, models, functions).
```
GET /v1/components
```
**Response:**
```json
{
"components": [
{
"component_id": "llm-inference",
"base_url": "http://didiAI-llm-api:14011",
"resource": { "name": "LLM Inference Gateway", "slug": "llm-inference", ... },
"models": [...],
"functions": [...]
}
],
"total": 4,
"errors": null
}
```
---
### Get Component
Get metadata for a specific component by ID.
```
GET /v1/components/{component_id}
```
**Path Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `component_id` | string | Component ID (e.g., `llm-inference`, `audio-transcription`, `video-analysis`, `web-factcheck`) |
**Response:** Same structure as a single component from `/v1/components`.
**Error (404):**
```json
{
"detail": "Component 'unknown' not found"
}
```
---
### List Models
List all available models across all components.
```
GET /v1/models
```
**Response:**
```json
{
"models": [
{
"name": "Qwen3.5-35B-A3B",
"slug": "qwen3.5",
"provider": "vllm",
"model_type": "llm",
"component_id": "llm-inference",
"component_name": "LLM Inference Gateway"
}
],
"total": 5
}
```
---
### List Functions
List all available functions/endpoints across all components.
```
GET /v1/functions
```
**Response:**
```json
{
"functions": [
{
"name": "Chat Completions",
"slug": "chat-completions",
"method": "POST",
"path": "/v1/chat/completions",
"component_id": "llm-inference"
}
],
"total": 10
}
```
---
### System Status
Aggregated health status of all components.
```
GET /v1/status
```
**Response:**
```json
{
"status": "healthy",
"components": [
{
"component_id": "llm-inference",
"url": "http://didiAI-llm-api:14011",
"reachable": true,
"healthy": true,
"status_code": 200
}
],
"healthy_count": 4,
"total_count": 4
}
```
**Status values:** `healthy` (all ok), `degraded` (some ok), `unhealthy` (none ok).
---
### Aggregated OpenAPI Spec
Combined OpenAPI 3.1.0 specification from all components.
```
GET /v1/openapi
```
**Response:** Full OpenAPI JSON spec with paths prefixed by `/{component_id}` and schemas prefixed by `{component_id}_`.
---
### Swagger UI
Interactive API documentation (Swagger UI) for the aggregated spec.
```
GET /v1/docs
```
---
### ReDoc
Alternative API documentation (ReDoc) for the aggregated spec.
```
GET /v1/redoc
```
---
### Component OpenAPI Spec
Raw OpenAPI spec for a single component.
```
GET /v1/openapi/component/{component_id}
```
---
## Error Responses
| Code | Description |
|------|-------------|
| 404 | Component not found |
| 500 | Internal error (component unreachable) |
## Request Headers
| Header | Required | Description |
|--------|----------|-------------|
| `Accept` | No | `application/json` (default) |

View file

@ -0,0 +1,146 @@
# catalog-api — INDEX
Catalog API for the DIDI AI platform. It is a **service registry and discovery gateway**: it aggregates `/v1/info` responses from each ML component (LLM, Audio, Video, Web) and re-exposes a unified view (components, models, functions, status) plus a merged OpenAPI 3.1 spec with Swagger UI / ReDoc. Production port `11000`, container `didiAI-catalog-api`. Read-only HTTP aggregator — no database, no writes.
- **Stack:** Python 3.11, FastAPI, httpx (async), pydantic-settings, uv, uvicorn
- **Container:** `didiAI-catalog-api`
- **Internal URL:** `http://didiAI-catalog-api:11000`
- **Production host:** `http://10.11.10.42` (per `CATALOG_EXTERNAL_URL` in `deploy/.env.example`)
- **Network:** Docker external network `didi-network` (shared with the other ai_platform modules)
- **Sister CLAUDE.md (platform):** `/home/admin365/didi_mono/ai_platform/CLAUDE.md`
> Note: despite the name, this module is **not** a knowledge-graph / atom catalog. It is a *service catalog* (think "service registry" in the microservices sense). It does not talk to PostgreSQL, Atomic, Redis, or didi-brain.
---
## Ce face
- Aggregates static metadata from each ML component by calling `GET /v1/info` on the configured backends.
- Returns a single unified response with:
- **Resources** — component descriptor (`name`, `slug`, `resource_type`, ...).
- **Models** — every model exposed by every backend (LLM, Whisper, vision, etc.).
- **Functions** — every endpoint/function each backend advertises.
- Probes liveness of every backend and reports aggregated health (`healthy` / `degraded` / `unhealthy`).
- Builds a **merged OpenAPI 3.1.0 document** (paths prefixed with `/{component_id}`, schemas prefixed with `{component_id}_`) and serves it as JSON, Swagger UI, and ReDoc — so a frontend or backend can consume a single contract for all GPU services.
- Tolerant to partial outages: if a component is unreachable it is recorded under `errors` and skipped, the rest of the catalog still serves.
---
## API endpoints
Source: `src/catalog_api/app.py` and `API.md`.
| Method | Path | Description |
|--------|------|-------------|
| GET | `/health` | Liveness probe (`{"status":"ok"}`). |
| GET | `/v1/components` | List all registered components with full metadata (resource + models + functions). |
| GET | `/v1/components/{component_id}` | Full metadata for one component (404 if unknown). |
| GET | `/v1/models` | Flattened list of every model across components. |
| GET | `/v1/functions` | Flattened list of every function/endpoint across components. |
| GET | `/v1/status` | Aggregated reachability + health of all components (`healthy_count`/`total_count`). |
| GET | `/v1/openapi` | Merged OpenAPI 3.1 spec (all components, prefixed). |
| GET | `/v1/docs` | Swagger UI for the merged spec. |
| GET | `/v1/redoc` | ReDoc for the merged spec. |
| GET | `/v1/openapi/component/{component_id}` | Raw OpenAPI spec of a single component (passthrough). |
Status values from `/v1/status`: `healthy` (all reachable), `degraded` (some reachable), `unhealthy` (none reachable).
When accessed through the gateway, Bearer-token auth is enforced by nginx (`<host>:11000/catalog/`); direct access is unauthenticated by design.
---
## Architecture
```
+-----------------------------------------------------+
| Catalog API (:11000) — didiAI-catalog-api |
| - Calls /v1/info on each backend |
| - Merges OpenAPI specs, exposes Swagger UI / ReDoc |
| - Stateless, no DB |
+-------+----------+----------+----------+------------+
| | | |
v v v v
LLM 14011 Audio 54300 Video 54600 Web 51100
didiAI-llm-api -audio-api -video-api -web-api
```
- **Pure aggregator** — no persistence, no caching layer; a fresh fan-out happens per request via `httpx.AsyncClient`.
- **Component list is config-driven** (`CatalogSettings.get_components()` in `settings.py`): a component with an empty URL is silently dropped (used today to disable Video by setting `CATALOG_VIDEO_URL=""`).
- **External-vs-internal URL split** — internal URLs (`*_URL`) are used for live calls inside the Docker network; the `CATALOG_EXTERNAL_URL` + `*_EXTERNAL_PORT` pair is the public base URL injected into the merged OpenAPI `servers:` so that external clients hit the right hostnames/ports.
### How it is consumed
- **Frontend / API gateway** — fetches `/v1/openapi` to expose Swagger UI for the whole platform; fetches `/v1/status` for a system-health widget.
- **Backend integrations** — pull `/v1/components` to populate their own catalog tables (`catalog.resources`, `catalog.models`, `catalog.functions`), as illustrated in `README.md` § Use Cases.
- **Service discovery** — clients query `/v1/models` to find a model by `model_type` (e.g., all `vision` models) without hard-coding hosts.
---
## Structura fișiere
```
catalog-api/
├── README.md Overview, quick-start, configuration, use cases
├── API.md Endpoint reference (request/response shapes)
├── INDEX.md This file
├── pyproject.toml Hatchling package, FastAPI/httpx/pydantic-settings deps
└── src/catalog_api/
├── __init__.py version = "0.1.0"
├── app.py FastAPI app — all endpoints + OpenAPI merger (~21 KB, single module)
└── settings.py CatalogSettings (env prefix CATALOG_) and Component model
└── deploy/
├── Dockerfile Multi-stage build: python:3.11.12-slim + uv 0.10, runs uvicorn
├── docker-compose.yml Defines didiAI-catalog-api on didi-network, expose:11000 only
├── deploy.sh Wrapper: loads .env, validates CATALOG_EXTERNAL_URL, runs compose
├── .env.example Documented environment variables
└── .env Local environment (CATALOG_EXTERNAL_URL=...)
```
Implementation footprint is tiny: one `app.py` (all endpoints + OpenAPI merger live there) plus one `settings.py`.
---
## Configuration
All settings come from environment variables with prefix `CATALOG_` (see `src/catalog_api/settings.py`).
| Variable | Default | Purpose |
|----------|---------|---------|
| `CATALOG_HOST` | `0.0.0.0` | Bind address. |
| `CATALOG_PORT` | `11000` | Bind port. |
| `CATALOG_LOG_LEVEL` | `INFO` | Python logging level. |
| `CATALOG_EXTERNAL_URL` | **required** | Public base URL (e.g., `http://10.11.10.42`) injected into the merged OpenAPI `servers:`. `deploy.sh` aborts if missing. |
| `CATALOG_LLM_URL` | `http://didiAI-llm-api:14011` | LLM Inference internal URL. |
| `CATALOG_AUDIO_URL` | `http://didiAI-audio-api:54300` | Audio API internal URL. |
| `CATALOG_VIDEO_URL` | *(empty)* | Video Analysis internal URL — empty string disables Video. |
| `CATALOG_WEB_URL` | `http://didiAI-web-api:51100` | Web API internal URL. |
| `CATALOG_LLM_EXTERNAL_PORT` | `14011` | External port advertised in the merged OpenAPI for LLM. |
| `CATALOG_AUDIO_EXTERNAL_PORT` | `54300` | External port for Audio. |
| `CATALOG_VIDEO_EXTERNAL_PORT` | `54600` | External port for Video. |
| `CATALOG_WEB_EXTERNAL_PORT` | `51100` | External port for Web. |
| `CATALOG_COMPONENT_TIMEOUT` | `10` | Per-call httpx timeout in seconds. |
---
## Deployment
- **Docker compose** (`deploy/docker-compose.yml`): builds `didiai-catalog-api`, attaches to external network `didi-network`, only `expose: 11000` (no host port — traffic comes through the platform gateway). Healthcheck hits `http://localhost:11000/health` every 30 s, restart policy `unless-stopped`.
- **Dockerfile** (`deploy/Dockerfile`): two-stage build using `ghcr.io/astral-sh/uv:0.10` for dependency install, then a slim `python:3.11.12-slim` runtime that runs `python -m uvicorn catalog_api.app:app`.
- **Helper script** (`deploy/deploy.sh`): loads `.env`, validates `CATALOG_EXTERNAL_URL`, supports `--detach`, `--down`, `--logs`.
- **Local dev** (per README.md): `uv sync && uv run python -m uvicorn catalog_api.app:app --host 0.0.0.0 --port 11000` (requires the listed components reachable on the network).
---
## Related modules
This service stands on top of the rest of the `ai_platform/modules/*` family — they are its data sources:
- `llm-inference` (port `14011`, container `didiAI-llm-api`) — chat, embeddings, rerank.
- `audio` (port `54300`, container `didiAI-audio-api`) — transcription / TTS.
- `video-analysis` (port `54600`, container `didiAI-video-api`) — vision pipelines (currently disabled by default in the .env example).
- `web` (port `51100`, container `didiAI-web-api`) — fact-check / web crawler.
- `dashboard` — primary frontend consumer of the merged OpenAPI / `/v1/status`.
It is **independent** of:
- `didi-brain`, Atomic / knowledge-graph services, PostgreSQL, Redis — none of these are accessed.
- The orchestration-layer (`agent-v3`) does not currently consume this catalog; it talks to the GPU services directly.

View file

@ -0,0 +1,253 @@
# Catalog API
**Service catalog and discovery** - aggregates component information from all ML services.
## What It Does
This module provides a unified API to discover and query all available ML services, models, and endpoints in the system. It aggregates `/v1/info` from all registered components and exposes:
- Component metadata (resources)
- Available models across all services
- Available functions/endpoints
- Health status of all components
## Prerequisites
**Required:**
- All global prerequisites (see main [README.md](../../README.md))
- Docker network `deploy_default` (shared with other modules)
- At least one other module running (llm-inference, audio, video-analysis, or web)
## Quick Start
```bash
cd deploy/
# Start the catalog API
docker compose up -d
# Check health
curl http://localhost:11000/health
# List all components
curl http://localhost:11000/v1/components | jq
# List all models
curl http://localhost:11000/v1/models | jq
# Get component status
curl http://localhost:11000/v1/status | jq
```
## API Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/health` | GET | Health check |
| `/v1/components` | GET | List all components with full metadata |
| `/v1/components/{component_id}` | GET | Get specific component info |
| `/v1/models` | GET | List all available models |
| `/v1/functions` | GET | List all available functions/endpoints |
| `/v1/status` | GET | Aggregated health status |
| `/v1/openapi` | GET | Aggregated OpenAPI 3.1.0 spec (all components) |
| `/v1/docs` | GET | Swagger UI for aggregated API |
| `/v1/redoc` | GET | ReDoc for aggregated API |
| `/v1/openapi/component/{component_id}` | GET | OpenAPI spec for a single component |
## Configuration
Configure via environment variables (prefix: `CATALOG_`):
| Variable | Default | Description |
|----------|---------|-------------|
| `CATALOG_HOST` | `0.0.0.0` | Server host |
| `CATALOG_PORT` | `11000` | Server port (Production API Gateway: 11000) |
| `CATALOG_LOG_LEVEL` | `INFO` | Log level |
| `CATALOG_LLM_URL` | `http://didiAI-llm-api:14011` | LLM Inference API URL |
| `CATALOG_AUDIO_URL` | `http://didiAI-audio-api:54300` | Audio API URL |
| `CATALOG_VIDEO_URL` | `http://didiAI-video-api:54600` | Video Analysis API URL |
| `CATALOG_WEB_URL` | `http://didiAI-web-api:51100` | Web API URL |
| `CATALOG_COMPONENT_TIMEOUT` | `10` | Component request timeout (seconds) |
## Example Responses
### List Components
```bash
curl http://localhost:11000/v1/components | jq
```
```json
{
"components": [
{
"component_id": "llm-inference",
"base_url": "http://didiAI-llm-api:14011",
"resource": {
"name": "LLM Inference Gateway",
"slug": "llm-inference",
"resource_type": "api_service",
...
},
"models": [...],
"functions": [...]
},
{
"component_id": "audio-transcription",
...
}
],
"total": 4,
"errors": null
}
```
### List All Models
```bash
curl http://localhost:11000/v1/models | jq
```
```json
{
"models": [
{
"name": "Qwen3.5-35B-A3B",
"slug": "qwen3.5",
"provider": "vllm",
"model_type": "llm",
"component_id": "llm-inference",
"component_name": "LLM Inference Gateway",
...
},
{
"name": "Whisper large-v3-turbo",
"component_id": "audio-transcription",
...
}
],
"total": 5
}
```
### Component Status
```bash
curl http://localhost:11000/v1/status | jq
```
```json
{
"status": "healthy",
"components": [
{
"component_id": "llm-inference",
"url": "http://didiAI-llm-api:14011",
"reachable": true,
"healthy": true,
"status_code": 200
},
{
"component_id": "audio-transcription",
"reachable": true,
"healthy": true,
"status_code": 200
}
],
"healthy_count": 4,
"total_count": 4
}
```
## Use Cases
### 1. Backend System Integration
Your backend can pull all service metadata and populate the database:
```python
import requests
# Pull all components
response = requests.get("http://localhost:11000/v1/components")
components = response.json()["components"]
for comp in components:
# Populate catalog.resources
db.insert_resource(comp["resource"])
# Populate catalog.models
for model in comp.get("models", []):
db.insert_model(model)
# Populate catalog.functions
for function in comp.get("functions", []):
db.insert_function(function)
```
### 2. Service Discovery
```python
# Find all vision models
response = requests.get("http://localhost:11000/v1/models")
models = response.json()["models"]
vision_models = [m for m in models if m["model_type"] == "vision"]
print(f"Found {len(vision_models)} vision models")
```
### 3. Health Monitoring
```python
# Check system health
response = requests.get("http://localhost:11000/v1/status")
status = response.json()
if status["status"] != "healthy":
alert(f"System degraded: {status['healthy_count']}/{status['total_count']} healthy")
```
## Architecture
```
+-----------------------------------------------------+
| Catalog API (11000) |
| - Aggregates /v1/info from all components |
| - No database, just HTTP aggregation |
| - Read-only, no writes |
+----------------+------------------------------------+
|
+-----------+-----------+-----------+
v v v v
+--------+ +---------+ +--------+ +----------+
| LLM | | Audio | | Video | | Web |
| 14011 | | 54300 | | 54600 | | 51100 |
+--------+ +---------+ +--------+ +----------+
```
## Development
```bash
# Install dependencies
uv sync
# Run locally (requires components running)
uv run python -m uvicorn catalog_api.app:app --host 0.0.0.0 --port 11000
# Test
curl http://localhost:11000/v1/components | jq
```
## Dependencies on Other Modules
This module aggregates information from:
- `llm-inference` (port 14011, Docker internal: didiAI-llm-api)
- `audio` (port 54300, Docker internal: didiAI-audio-api)
- `video-analysis` (port 54600, Docker internal: didiAI-video-api)
- `web` (port 51100, Docker internal: didiAI-web-api)
**Note:** The catalog API can function with partial availability. If a component is unavailable, it will be skipped with a warning in the logs.
## License
MIT

View file

@ -0,0 +1,32 @@
# Catalog API Configuration
# =============================================================================
# Required Configuration (no defaults)
# =============================================================================
# External URL for OpenAPI spec - REQUIRED
# This is the URL that external clients will use to access the APIs
CATALOG_EXTERNAL_URL=http://10.11.10.42
# =============================================================================
# Optional Configuration (has defaults)
# =============================================================================
# Server settings
# CATALOG_HOST=0.0.0.0
# CATALOG_PORT=11000
# CATALOG_LOG_LEVEL=INFO
# Component URLs (Docker internal network)
# CATALOG_LLM_URL=http://didiAI-llm-api:14011
# CATALOG_AUDIO_URL=http://didiAI-audio-api:54300
# CATALOG_VIDEO_URL=http://didiAI-video-api:54600
# CATALOG_WEB_URL=http://didiAI-web-api:51100
# External ports for OpenAPI spec (match your docker-compose port mappings)
# CATALOG_LLM_EXTERNAL_PORT=14011
# CATALOG_AUDIO_EXTERNAL_PORT=54300
# CATALOG_VIDEO_EXTERNAL_PORT=54600
# CATALOG_WEB_EXTERNAL_PORT=51100
# HTTP client settings
# CATALOG_COMPONENT_TIMEOUT=10

View file

@ -0,0 +1,39 @@
# Catalog API - Dockerfile
FROM python:3.11.12-slim AS builder
# Install uv
COPY --from=ghcr.io/astral-sh/uv:0.10 /uv /usr/local/bin/uv
WORKDIR /app
# Copy dependency files
COPY pyproject.toml ./
# Copy source code
COPY src/ ./src/
# Install dependencies (no lockfile yet)
RUN uv sync --no-dev
# Final stage
FROM python:3.11.12-slim
WORKDIR /app
# Copy virtual environment from builder
COPY --from=builder /app/.venv /app/.venv
COPY src/ ./src/
# Set environment
ENV PATH="/app/.venv/bin:$PATH"
ENV PYTHONUNBUFFERED=1
ENV CATALOG_HOST=0.0.0.0
ENV CATALOG_PORT=11000
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
CMD python -c "import urllib.request; import os; urllib.request.urlopen(f'http://localhost:{os.environ.get(\"CATALOG_PORT\", 11000)}/health')" || exit 1
EXPOSE 11000
CMD python -m uvicorn catalog_api.app:app --host 0.0.0.0 --port ${CATALOG_PORT}

View file

@ -0,0 +1,99 @@
#!/usr/bin/env bash
#
# Docker Compose Startup Script for Catalog API
#
# Usage: ./deploy/deploy.sh [OPTIONS]
#
# Options:
# --detach, -d Run in detached mode
# --down Stop and remove containers
# --logs Show logs
# --help, -h Show this help message
#
# Required: Set environment variables in .env file or export them before running.
# See .env.example for the full list of required variables.
set -euo pipefail
# Script directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Load .env file if it exists
ENV_FILE=""
if [[ -f "$SCRIPT_DIR/.env" ]]; then
ENV_FILE="$SCRIPT_DIR/.env"
elif [[ -f "$SCRIPT_DIR/../.env" ]]; then
ENV_FILE="$SCRIPT_DIR/../.env"
fi
if [[ -n "$ENV_FILE" ]]; then
echo "Loading environment from: $ENV_FILE"
set -a
source "$ENV_FILE"
set +a
fi
DETACH=""
ACTION="up"
show_help() {
sed -n '2,16p' "$0" | sed 's/^# //' | sed 's/^#//'
exit 0
}
check_required_var() {
local var_name="$1"
if [[ -z "${!var_name:-}" ]]; then
echo "ERROR: Required environment variable $var_name is not set"
echo "Set it in .env file or export it before running this script"
exit 1
fi
}
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
--detach|-d)
DETACH="-d"
shift
;;
--down)
ACTION="down"
shift
;;
--logs)
ACTION="logs"
shift
;;
--help|-h)
show_help
;;
*)
echo "Unknown option: $1"
echo "Use --help for usage information"
exit 1
;;
esac
done
# Check required variables
check_required_var "CATALOG_EXTERNAL_URL"
cd "$SCRIPT_DIR"
case $ACTION in
up)
echo "Starting Catalog API"
echo " External URL: $CATALOG_EXTERNAL_URL"
echo ""
# shellcheck disable=SC2086
exec docker compose up $DETACH
;;
down)
echo "Stopping Catalog API containers..."
exec docker compose down
;;
logs)
exec docker compose logs -f
;;
esac

View file

@ -0,0 +1,64 @@
# Catalog API - Docker Compose Configuration
#
# Port Allocation (Production API Gateway: 11000):
# 11000 - Catalog API (main orchestrator gateway)
#
# Network:
# Uses deploy_default network (shared with other modules)
#
# Naming Convention: didiAI-{module}-{service}
networks:
didi-network:
external: true # single shared network for all DIDI + AI platform stacks
services:
catalog-api:
container_name: didiAI-catalog-api
image: didiai-catalog-api
build:
context: ..
dockerfile: deploy/Dockerfile
# No external port - accessible only via Gateway (:11000/catalog/)
expose:
- "11000"
networks:
- didi-network
environment:
# Server settings
- CATALOG_HOST=0.0.0.0
- CATALOG_PORT=11000
- CATALOG_LOG_LEVEL=${CATALOG_LOG_LEVEL:-INFO}
# External URL for OpenAPI spec (REQUIRED - no default)
- CATALOG_EXTERNAL_URL=${CATALOG_EXTERNAL_URL}
# Component URLs (Docker internal network)
- CATALOG_LLM_URL=${CATALOG_LLM_URL:-http://didiAI-llm-api:14011}
- CATALOG_AUDIO_URL=${CATALOG_AUDIO_URL:-http://didiAI-audio-api:54300}
- CATALOG_VIDEO_URL=${CATALOG_VIDEO_URL}
- CATALOG_WEB_URL=${CATALOG_WEB_URL:-http://didiAI-web-api:51100}
# External ports for OpenAPI spec (used when generating external URLs)
- CATALOG_LLM_EXTERNAL_PORT=${CATALOG_LLM_EXTERNAL_PORT:-14011}
- CATALOG_AUDIO_EXTERNAL_PORT=${CATALOG_AUDIO_EXTERNAL_PORT:-54300}
- CATALOG_VIDEO_EXTERNAL_PORT=${CATALOG_VIDEO_EXTERNAL_PORT:-54600}
- CATALOG_WEB_EXTERNAL_PORT=${CATALOG_WEB_EXTERNAL_PORT:-51100}
# HTTP client settings
- CATALOG_COMPONENT_TIMEOUT=${CATALOG_COMPONENT_TIMEOUT:-10}
# Runtime config polling
- CATALOG_DASHBOARD_URL=${CATALOG_DASHBOARD_URL:-http://didiAI-dashboard:51300}
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:11000/health')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
restart: unless-stopped

View file

@ -0,0 +1,32 @@
[project]
name = "catalog-api"
version = "0.1.0"
description = "Service catalog API - aggregates component information"
requires-python = ">=3.10"
dependencies = [
"fastapi>=0.128.0",
"uvicorn[standard]>=0.30.0",
"httpx>=0.28.0",
"pydantic>=2.0",
"pydantic-settings>=2.0",
"prometheus-fastapi-instrumentator>=7.0.0",
"opentelemetry-instrumentation-fastapi>=0.50b0",
"opentelemetry-exporter-otlp-proto-grpc>=1.30.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-cov>=4.0",
"ruff>=0.8",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/catalog_api"]
[tool.ruff]
extend = "../../ruff.toml"

View file

@ -0,0 +1,3 @@
"""Catalog API - Service Registry & Discovery."""
__version__ = "0.1.0"

View file

@ -0,0 +1,730 @@
"""Catalog API - Service Registry & Discovery."""
import copy
import logging
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from typing import Any
import httpx
from fastapi import FastAPI, HTTPException
from fastapi.responses import HTMLResponse, JSONResponse
from .runtime_config import RuntimeConfigClient
from .settings import settings
# OpenAPI aggregation version
AGGREGATED_OPENAPI_VERSION = "0.1.0"
# Configure logging
logging.basicConfig(
level=getattr(logging, settings.log_level.upper()),
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
# Runtime config (reachable via app.state.runtime_config)
runtime_config = RuntimeConfigClient(
dashboard_url=settings.dashboard_url,
live_log_logger_name="catalog_api",
live_log_key="catalog.log.level",
)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""App lifespan: start runtime config polling."""
logger.info("Starting Catalog API")
await runtime_config.start()
app.state.runtime_config = runtime_config
yield
logger.info("Shutting down Catalog API")
await runtime_config.stop()
app = FastAPI(
title="Catalog API",
description="Service catalog and discovery - aggregates component information",
version="0.1.0",
lifespan=lifespan,
)
# ---------------------------------------------------------------- observability
# Prometheus /metrics + OTel tracing (no-op if deps missing or OTEL endpoint unset)
try:
from prometheus_fastapi_instrumentator import Instrumentator as _Inst # type: ignore
_Inst(should_group_status_codes=True).instrument(app).expose(app, endpoint="/metrics", include_in_schema=False)
except ImportError:
pass
import os as _os # noqa: E402
_otel_ep = _os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
if _otel_ep:
try:
from opentelemetry import trace as _trace # type: ignore
from opentelemetry.sdk.resources import Resource as _R # type: ignore
from opentelemetry.sdk.trace import TracerProvider as _TP # type: ignore
from opentelemetry.sdk.trace.export import BatchSpanProcessor as _BSP # type: ignore
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter as _Exp # type: ignore
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor as _FInst # type: ignore
_provider = _TP(resource=_R.create({"service.name": _os.environ.get("OTEL_SERVICE_NAME", "didiAI-catalog-api")}))
_provider.add_span_processor(_BSP(_Exp(endpoint=_otel_ep, insecure=True)))
_trace.set_tracer_provider(_provider)
_FInst.instrument_app(app)
print(f"[otel] didiAI-catalog-api instrumented -> {_otel_ep}")
except ImportError as _e:
print(f"[otel] skip: {_e}")
@app.get("/health")
def health():
"""Health check endpoint."""
return {"status": "ok"}
@app.get("/v1/components")
async def list_components() -> JSONResponse:
"""
List all registered components with their metadata.
Aggregates /v1/info from all configured components and returns
a unified response containing:
- Component resource information
- Available models
- Available functions/endpoints
Returns:
JSONResponse: List of components with full metadata
"""
components = []
errors = []
async with httpx.AsyncClient() as client:
for component in settings.get_components():
try:
logger.info(f"Fetching info from {component.id} at {component.url}/v1/info")
response = await client.get(
f"{component.url}/v1/info",
timeout=component.timeout,
)
response.raise_for_status()
data = response.json()
# Add component ID to the response
data["component_id"] = component.id
data["base_url"] = component.url
components.append(data)
logger.info(f"✓ Successfully fetched info from {component.id}")
except httpx.TimeoutException:
error_msg = f"Timeout fetching {component.id}"
logger.warning(error_msg)
errors.append({"component_id": component.id, "error": "timeout", "url": component.url})
except httpx.HTTPStatusError as e:
error_msg = f"HTTP {e.response.status_code} from {component.id}"
logger.warning(error_msg)
errors.append({
"component_id": component.id,
"error": f"http_{e.response.status_code}",
"url": component.url,
})
except Exception as e:
error_msg = f"Error fetching {component.id}: {str(e)}"
logger.error(error_msg)
errors.append({
"component_id": component.id,
"error": str(e),
"url": component.url,
})
return JSONResponse(
content={
"components": components,
"total": len(components),
"errors": errors if errors else None,
}
)
@app.get("/v1/components/{component_id}")
async def get_component(component_id: str) -> JSONResponse:
"""
Get information for a specific component.
Args:
component_id: Component identifier (e.g., "llm-inference", "audio-transcription")
Returns:
JSONResponse: Component metadata
Raises:
HTTPException: If component not found or unreachable
"""
# Find component configuration
component_config = None
for comp in settings.get_components():
if comp.id == component_id:
component_config = comp
break
if not component_config:
raise HTTPException(
status_code=404,
detail=f"Component '{component_id}' not found. Available: {[c.id for c in settings.get_components()]}",
)
# Fetch component info
try:
async with httpx.AsyncClient() as client:
response = await client.get(
f"{component_config.url}/v1/info",
timeout=component_config.timeout,
)
response.raise_for_status()
data = response.json()
# Add component ID
data["component_id"] = component_id
data["base_url"] = component_config.url
return JSONResponse(content=data)
except httpx.TimeoutException:
raise HTTPException(
status_code=504,
detail=f"Timeout fetching {component_id} from {component_config.url}",
)
except httpx.HTTPStatusError as e:
raise HTTPException(
status_code=e.response.status_code,
detail=f"HTTP error from {component_id}: {e.response.text}",
)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Error fetching {component_id}: {str(e)}",
)
@app.get("/v1/models")
async def list_models() -> JSONResponse:
"""
List all available models across all components.
Extracts model information from all components and returns
a unified list with component attribution.
Returns:
JSONResponse: List of all models with metadata
"""
all_models = []
async with httpx.AsyncClient() as client:
for component in settings.get_components():
try:
response = await client.get(
f"{component.url}/v1/info",
timeout=component.timeout,
)
response.raise_for_status()
data = response.json()
# Extract models and add component context
for model in data.get("models", []):
model["component_id"] = component.id
model["component_name"] = data.get("resource", {}).get("name", component.id)
all_models.append(model)
except Exception as e:
logger.warning(f"Skipping {component.id} due to error: {e}")
continue
return JSONResponse(
content={
"models": all_models,
"total": len(all_models),
}
)
@app.get("/v1/functions")
async def list_functions() -> JSONResponse:
"""
List all available functions/endpoints across all components.
Extracts function information from all components and returns
a unified list with component attribution.
Returns:
JSONResponse: List of all functions with metadata
"""
all_functions = []
async with httpx.AsyncClient() as client:
for component in settings.get_components():
try:
response = await client.get(
f"{component.url}/v1/info",
timeout=component.timeout,
)
response.raise_for_status()
data = response.json()
# Extract functions and add component context
for function in data.get("functions", []):
function["component_id"] = component.id
function["component_name"] = data.get("resource", {}).get("name", component.id)
all_functions.append(function)
except Exception as e:
logger.warning(f"Skipping {component.id} due to error: {e}")
continue
return JSONResponse(
content={
"functions": all_functions,
"total": len(all_functions),
}
)
@app.get("/v1/status")
async def get_status() -> JSONResponse:
"""
Get aggregated status of all components.
Checks health/connectivity of all registered components
and returns their status.
Returns:
JSONResponse: Status summary for all components
"""
component_status = []
async with httpx.AsyncClient() as client:
for component in settings.get_components():
status_entry = {
"component_id": component.id,
"url": component.url,
"reachable": False,
"healthy": False,
}
try:
# Try to fetch /v1/info
response = await client.get(
f"{component.url}/v1/info",
timeout=component.timeout,
)
response.raise_for_status()
status_entry["reachable"] = True
status_entry["healthy"] = True
status_entry["status_code"] = response.status_code
except httpx.TimeoutException:
status_entry["error"] = "timeout"
except httpx.HTTPStatusError as e:
status_entry["reachable"] = True
status_entry["status_code"] = e.response.status_code
status_entry["error"] = f"http_{e.response.status_code}"
except Exception as e:
status_entry["error"] = str(e)
component_status.append(status_entry)
# Determine overall status
healthy_count = sum(1 for s in component_status if s["healthy"])
total_count = len(component_status)
if healthy_count == total_count:
overall_status = "healthy"
elif healthy_count > 0:
overall_status = "degraded"
else:
overall_status = "unhealthy"
return JSONResponse(
content={
"status": overall_status,
"components": component_status,
"healthy_count": healthy_count,
"total_count": total_count,
}
)
def _merge_openapi_schemas(
base_spec: dict[str, Any],
component_spec: dict[str, Any],
component_id: str,
component_url: str,
) -> None:
"""
Merge a component's OpenAPI spec into the base aggregated spec.
Args:
base_spec: The aggregated OpenAPI spec to merge into (modified in place).
component_spec: The component's OpenAPI spec to merge.
component_id: Component identifier for prefixing paths.
component_url: Component's base URL for server info.
"""
# Merge paths with component prefix
component_paths = component_spec.get("paths", {})
for path, path_item in component_paths.items():
# Prefix path with component ID to avoid collisions
prefixed_path = f"/{component_id}{path}"
# Deep copy to avoid modifying original
new_path_item = copy.deepcopy(path_item)
# Add component tag to all operations
for method in ["get", "post", "put", "delete", "patch", "options", "head"]:
if method in new_path_item:
operation = new_path_item[method]
# Add component as a tag
existing_tags = operation.get("tags", [])
if component_id not in existing_tags:
operation["tags"] = [component_id] + existing_tags
# Update operationId to be unique
if "operationId" in operation:
operation["operationId"] = f"{component_id}_{operation['operationId']}"
# Add server override for this path
operation["servers"] = [{"url": component_url}]
base_spec["paths"][prefixed_path] = new_path_item
# Merge components/schemas with component prefix
component_schemas = component_spec.get("components", {}).get("schemas", {})
if "components" not in base_spec:
base_spec["components"] = {}
if "schemas" not in base_spec["components"]:
base_spec["components"]["schemas"] = {}
for schema_name, schema_def in component_schemas.items():
# Prefix schema name to avoid collisions
prefixed_name = f"{component_id}_{schema_name}"
base_spec["components"]["schemas"][prefixed_name] = copy.deepcopy(schema_def)
# Update $ref references in the schema
_update_refs(base_spec["components"]["schemas"][prefixed_name], component_id)
# Update $refs in paths to use prefixed schema names (only for this component's paths)
for path, path_item in base_spec["paths"].items():
if path.startswith(f"/{component_id}"):
for method_item in path_item.values():
if isinstance(method_item, dict):
_update_refs(method_item, component_id)
def _update_refs(obj: Any, component_id: str) -> None:
"""
Recursively update $ref references to use component-prefixed schema names.
Args:
obj: Object to update (modified in place).
component_id: Component identifier for prefixing.
"""
if isinstance(obj, dict):
for key, value in obj.items():
if key == "$ref" and isinstance(value, str):
# Update reference: #/components/schemas/Name -> #/components/schemas/component_Name
if value.startswith("#/components/schemas/"):
schema_name = value.split("/")[-1]
obj[key] = f"#/components/schemas/{component_id}_{schema_name}"
else:
_update_refs(value, component_id)
elif isinstance(obj, list):
for item in obj:
_update_refs(item, component_id)
@app.get("/v1/openapi")
async def get_aggregated_openapi() -> JSONResponse:
"""
Get aggregated OpenAPI specification from all components.
Fetches /openapi.json from each registered component and merges them
into a single unified OpenAPI 3.x specification.
Features:
- Paths are prefixed with component ID (e.g., /llm-inference/v1/models)
- Schemas are prefixed to avoid naming collisions
- Each operation includes server override pointing to the actual component
- Components are organized by tags
Returns:
JSONResponse: Aggregated OpenAPI 3.1.0 specification
"""
# Base aggregated spec
aggregated_spec: dict[str, Any] = {
"openapi": "3.1.0",
"info": {
"title": "didiAI - Aggregated ML Services API",
"description": (
"Unified OpenAPI specification aggregating all ML service endpoints.\n\n"
"## Components\n"
"- **llm-inference**: LLM inference gateway (chat completions, models)\n"
"- **audio-transcription**: Speech-to-text transcription\n"
"- **video-analysis**: Deepfake detection and semantic analysis\n"
"- **web-factcheck**: Web search and evidence gathering\n"
),
"version": AGGREGATED_OPENAPI_VERSION,
"contact": {"name": "didiAI Team"},
},
"servers": [
{"url": f"{settings.external_url}:{settings.port}", "description": "Catalog API (aggregator)"},
],
"paths": {},
"components": {"schemas": {}},
"tags": [],
}
errors = []
successful_components = []
async with httpx.AsyncClient() as client:
for component in settings.get_components():
try:
logger.info(f"Fetching OpenAPI from {component.id} at {component.url}/openapi.json")
response = await client.get(
f"{component.url}/openapi.json",
timeout=component.timeout,
)
response.raise_for_status()
component_spec = response.json()
# Add component as a tag
component_info = component_spec.get("info", {})
aggregated_spec["tags"].append({
"name": component.id,
"description": component_info.get("description", f"{component.id} API"),
"externalDocs": {"url": f"{component.external_url}/docs"},
})
# Merge component spec into aggregated (use external URL for OpenAPI)
_merge_openapi_schemas(
aggregated_spec,
component_spec,
component.id,
component.external_url,
)
successful_components.append(component.id)
logger.info(f"✓ Successfully merged OpenAPI from {component.id}")
except httpx.TimeoutException:
error_msg = f"Timeout fetching OpenAPI from {component.id}"
logger.warning(error_msg)
errors.append({"component_id": component.id, "error": "timeout"})
except httpx.HTTPStatusError as e:
error_msg = f"HTTP {e.response.status_code} from {component.id}"
logger.warning(error_msg)
errors.append({"component_id": component.id, "error": f"http_{e.response.status_code}"})
except Exception as e:
error_msg = f"Error fetching OpenAPI from {component.id}: {str(e)}"
logger.error(error_msg)
errors.append({"component_id": component.id, "error": str(e)})
# Add metadata about aggregation
aggregated_spec["info"]["x-aggregation"] = {
"components_included": successful_components,
"components_failed": [e["component_id"] for e in errors],
"errors": errors if errors else None,
}
return JSONResponse(content=aggregated_spec)
@app.get("/v1/docs", response_class=HTMLResponse, include_in_schema=False)
async def get_aggregated_swagger_ui() -> HTMLResponse:
"""
Swagger UI for the aggregated OpenAPI specification.
Provides an interactive documentation interface for all ML services.
"""
html_content = f"""
<!DOCTYPE html>
<html>
<head>
<title>didiAI - API Documentation</title>
<link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
<style>
body {{ margin: 0; padding: 0; }}
.swagger-ui .topbar {{ display: none; }}
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-standalone-preset.js"></script>
<script>
window.onload = function() {{
SwaggerUIBundle({{
url: "/v1/openapi",
dom_id: '#swagger-ui',
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
layout: "StandaloneLayout",
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
deepLinking: true,
showExtensions: true,
showCommonExtensions: true,
filter: true,
tagsSorter: "alpha",
operationsSorter: "alpha"
}});
}};
</script>
</body>
</html>
"""
return HTMLResponse(content=html_content)
@app.get("/v1/redoc", response_class=HTMLResponse, include_in_schema=False)
async def get_aggregated_redoc() -> HTMLResponse:
"""
ReDoc UI for the aggregated OpenAPI specification.
Provides a clean, readable documentation interface for all ML services.
"""
html_content = """
<!DOCTYPE html>
<html>
<head>
<title>didiAI - API Documentation</title>
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
<style>
body { margin: 0; padding: 0; }
</style>
</head>
<body>
<redoc spec-url='/v1/openapi'></redoc>
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"></script>
</body>
</html>
"""
return HTMLResponse(content=html_content)
@app.get("/v1/openapi/component/{component_id}")
async def get_component_openapi(component_id: str) -> JSONResponse:
"""
Get OpenAPI specification for a specific component.
Args:
component_id: Component identifier (e.g., "llm-inference", "audio-transcription")
Returns:
JSONResponse: Component's OpenAPI specification
Raises:
HTTPException: If component not found or unreachable
"""
# Find component configuration
component_config = None
for comp in settings.get_components():
if comp.id == component_id:
component_config = comp
break
if not component_config:
raise HTTPException(
status_code=404,
detail=f"Component '{component_id}' not found. Available: {[c.id for c in settings.get_components()]}",
)
try:
async with httpx.AsyncClient() as client:
response = await client.get(
f"{component_config.url}/openapi.json",
timeout=component_config.timeout,
)
response.raise_for_status()
return JSONResponse(content=response.json())
except httpx.TimeoutException:
raise HTTPException(
status_code=504,
detail=f"Timeout fetching OpenAPI from {component_id}",
)
except httpx.HTTPStatusError as e:
raise HTTPException(
status_code=e.response.status_code,
detail=f"HTTP error from {component_id}: {e.response.text}",
)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Error fetching OpenAPI from {component_id}: {str(e)}",
)
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"catalog_api.app:app",
host=settings.host,
port=settings.port,
log_level=settings.log_level.lower(),
)

View file

@ -0,0 +1,157 @@
"""Runtime config client — fetches config overrides from dashboard.
Polls the dashboard /api/config endpoint periodically and caches values
in memory. All accessors fall back to caller-supplied defaults if the
dashboard is unreachable or the key is missing.
Single source of truth = dashboard `KNOWN_KEYS`. This client carries no
local default registry; consumers pass their own fallback (typically the
pydantic settings value).
"""
import asyncio
import contextlib
import logging
from typing import Any
import httpx
logger = logging.getLogger(__name__)
class RuntimeConfigClient:
"""Polls dashboard /api/config and caches values in-process."""
def __init__(
self,
dashboard_url: str | None,
poll_interval_seconds: int = 30,
request_timeout: float = 5.0,
live_log_logger_name: str | None = None,
live_log_key: str | None = None,
) -> None:
self.dashboard_url = dashboard_url.rstrip("/") if dashboard_url else None
self.poll_interval = poll_interval_seconds
self._timeout = request_timeout
self._cache: dict[str, Any] = {}
self._client: httpx.AsyncClient | None = None
self._task: asyncio.Task[None] | None = None
self._enabled = bool(dashboard_url)
# Optional auto-apply: re-set log level on the named logger when the
# configured key changes value (e.g., `llm.log.level`).
self._live_log_logger_name = live_log_logger_name
self._live_log_key = live_log_key
self._last_log_level: str | None = None
@property
def enabled(self) -> bool:
return self._enabled
def get(self, key: str, default: Any = None) -> Any:
v = self._cache.get(key)
return v if v is not None else default
def get_bool(self, key: str, default: bool = False) -> bool:
v = self._cache.get(key)
return bool(v) if v is not None else default
def get_int(self, key: str, default: int = 0) -> int:
v = self._cache.get(key)
if v is None:
return default
try:
return int(v)
except (TypeError, ValueError):
return default
def get_float(self, key: str, default: float = 0.0) -> float:
v = self._cache.get(key)
if v is None:
return default
try:
return float(v)
except (TypeError, ValueError):
return default
def get_str(self, key: str, default: str = "") -> str:
v = self._cache.get(key)
return str(v) if v is not None else default
async def start(self) -> None:
if not self._enabled:
logger.info("RuntimeConfigClient disabled (no dashboard URL)")
return
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(
connect=2.0, read=self._timeout, write=2.0, pool=5.0
)
)
# Fetch once synchronously so first requests already have overrides
await self._refresh()
self._task = asyncio.create_task(self._loop())
logger.info(
"RuntimeConfigClient started (polling %s every %ds, %d keys cached)",
self.dashboard_url,
self.poll_interval,
len(self._cache),
)
async def stop(self) -> None:
if self._task is not None:
self._task.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await self._task
self._task = None
if self._client is not None and not self._client.is_closed:
await self._client.aclose()
self._client = None
async def _loop(self) -> None:
while True:
try:
await asyncio.sleep(self.poll_interval)
await self._refresh()
except asyncio.CancelledError:
raise
except Exception as e:
logger.debug("Config poll failed: %s", e)
async def _refresh(self) -> None:
if self._client is None or self.dashboard_url is None:
return
try:
resp = await self._client.get(f"{self.dashboard_url}/api/config")
resp.raise_for_status()
data = resp.json()
except Exception as e:
logger.debug("Config refresh failed: %s", e)
return
items = data.get("items", {})
new_cache: dict[str, Any] = {}
for key, entry in items.items():
new_cache[key] = entry.get("value")
self._cache = new_cache
self._maybe_apply_log_level()
def _maybe_apply_log_level(self) -> None:
"""Re-apply log level live if configured key changed."""
if not self._live_log_key or not self._live_log_logger_name:
return
new_level = self.get_str(self._live_log_key)
if not new_level:
return
if new_level == self._last_log_level:
return
try:
level_int = logging.getLevelName(new_level.upper())
if isinstance(level_int, int):
logging.getLogger(self._live_log_logger_name).setLevel(level_int)
self._last_log_level = new_level
logger.info(
"Log level for %s changed to %s (via runtime config)",
self._live_log_logger_name,
new_level,
)
except Exception as e:
logger.warning("Failed to apply log level %s: %s", new_level, e)

View file

@ -0,0 +1,93 @@
"""Catalog API settings."""
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Component(BaseSettings):
"""Component configuration."""
id: str
url: str # Internal URL (Docker network)
external_url: str # External URL for OpenAPI spec
timeout: int = 10
class CatalogSettings(BaseSettings):
"""Catalog API configuration."""
model_config = SettingsConfigDict(
env_prefix="CATALOG_",
extra="ignore",
)
# Server settings
host: str = Field(default="0.0.0.0")
port: int = Field(default=11000, description="Port (Catalog API: 11000)")
log_level: str = Field(default="INFO")
# External URL for OpenAPI spec (required for external access)
external_url: str = Field(
description="External base URL for API (e.g., http://10.11.10.42). Required, no default.",
)
# Components to aggregate (configured via docker network - internal API ports)
llm_url: str = Field(
default="http://didiAI-llm-api:14011",
description="LLM Inference API URL (internal)",
)
audio_url: str = Field(
default="http://didiAI-audio-api:54300",
description="Audio API URL (internal)",
)
video_url: str = Field(
default="",
description="Video Analysis API URL (internal, empty to disable)",
)
web_url: str = Field(
default="http://didiAI-web-api:51100",
description="Web API URL (internal)",
)
# External ports for components (used in OpenAPI spec)
llm_external_port: int = Field(default=14011, description="LLM API external port")
audio_external_port: int = Field(default=54300, description="Audio API external port")
video_external_port: int = Field(default=54600, description="Video API external port")
web_external_port: int = Field(default=51100, description="Web API external port")
# HTTP client settings
component_timeout: int = Field(
default=10,
description="Timeout for component requests (seconds)",
)
# Runtime config (dashboard polling)
dashboard_url: str | None = Field(
default=None,
description=(
"Optional dashboard base URL. When set, runtime_config polls "
"/api/config every 30s for live overrides."
),
)
def get_components(self) -> list[Component]:
"""Get list of configured components. Empty URLs are skipped."""
all_components = [
("llm-inference", self.llm_url, self.llm_external_port),
("audio-transcription", self.audio_url, self.audio_external_port),
("video-analysis", self.video_url, self.video_external_port),
("web-factcheck", self.web_url, self.web_external_port),
]
return [
Component(
id=cid,
url=url,
external_url=f"{self.external_url}:{port}",
timeout=self.component_timeout,
)
for cid, url, port in all_components
if url
]
settings = CatalogSettings()