Livrare LOT 1 - Didi
This commit is contained in:
commit
5380c3fc63
990 changed files with 133308 additions and 0 deletions
225
ai_platform/modules/catalog-api/API.md
Normal file
225
ai_platform/modules/catalog-api/API.md
Normal 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) |
|
||||
146
ai_platform/modules/catalog-api/INDEX.md
Normal file
146
ai_platform/modules/catalog-api/INDEX.md
Normal 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.
|
||||
253
ai_platform/modules/catalog-api/README.md
Normal file
253
ai_platform/modules/catalog-api/README.md
Normal 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
|
||||
32
ai_platform/modules/catalog-api/deploy/.env.example
Normal file
32
ai_platform/modules/catalog-api/deploy/.env.example
Normal 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
|
||||
39
ai_platform/modules/catalog-api/deploy/Dockerfile
Normal file
39
ai_platform/modules/catalog-api/deploy/Dockerfile
Normal 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}
|
||||
99
ai_platform/modules/catalog-api/deploy/deploy.sh
Normal file
99
ai_platform/modules/catalog-api/deploy/deploy.sh
Normal 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
|
||||
64
ai_platform/modules/catalog-api/deploy/docker-compose.yml
Normal file
64
ai_platform/modules/catalog-api/deploy/docker-compose.yml
Normal 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
|
||||
32
ai_platform/modules/catalog-api/pyproject.toml
Normal file
32
ai_platform/modules/catalog-api/pyproject.toml
Normal 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"
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
"""Catalog API - Service Registry & Discovery."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
730
ai_platform/modules/catalog-api/src/catalog_api/app.py
Normal file
730
ai_platform/modules/catalog-api/src/catalog_api/app.py
Normal 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(),
|
||||
)
|
||||
|
|
@ -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)
|
||||
93
ai_platform/modules/catalog-api/src/catalog_api/settings.py
Normal file
93
ai_platform/modules/catalog-api/src/catalog_api/settings.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue