Spaces:
Running on Zero
Running on Zero
Upload 60 files
Browse files- .env.example +8 -0
- .github/workflows/test.yml +4 -2
- README.md +53 -56
- app.py +24 -4
- config.py +7 -0
- core/executor.py +169 -0
- core/manager.py +91 -114
- core/queue.py +4 -1
- core/runtime.py +21 -1
- core/workflow.py +66 -52
- gradio_ui.py +331 -0
- packages.txt +4 -0
- requirements-test.txt +2 -0
- requirements.txt +2 -0
- routes/health.py +2 -1
- tests/test_api.py +9 -0
- tests/test_executor.py +52 -0
- tests/test_queue.py +14 -0
- tests/test_workflow.py +6 -5
.env.example
CHANGED
|
@@ -12,6 +12,14 @@ MAX_UPLOAD_MB=100
|
|
| 12 |
MODEL_CPU_OFFLOAD=true
|
| 13 |
MIXED_PRECISION=true
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
# Hugging Face token; create this as a Space secret, never commit it.
|
| 16 |
HF_TOKEN=
|
| 17 |
|
|
|
|
| 12 |
MODEL_CPU_OFFLOAD=true
|
| 13 |
MIXED_PRECISION=true
|
| 14 |
|
| 15 |
+
# Estimated ZeroGPU allocations in seconds. WAN uses the xlarge tier.
|
| 16 |
+
ZEROGPU_FLUX_DURATION=180
|
| 17 |
+
ZEROGPU_WAN_DURATION=300
|
| 18 |
+
ZEROGPU_KOKORO_DURATION=90
|
| 19 |
+
ZEROGPU_MUSICGEN_DURATION=180
|
| 20 |
+
ZEROGPU_WHISPER_DURATION=180
|
| 21 |
+
ZEROGPU_SFX_DURATION=180
|
| 22 |
+
|
| 23 |
# Hugging Face token; create this as a Space secret, never commit it.
|
| 24 |
HF_TOKEN=
|
| 25 |
|
.github/workflows/test.yml
CHANGED
|
@@ -16,8 +16,10 @@ jobs:
|
|
| 16 |
cache: pip
|
| 17 |
- run: python -m pip install -r requirements-test.txt
|
| 18 |
- run: python -m compileall -q .
|
| 19 |
-
- run:
|
|
|
|
|
|
|
| 20 |
- run: >-
|
| 21 |
python -m pycodestyle --max-line-length=100 --ignore=E203,W503
|
| 22 |
-
app.py config.py core gateway_mcp models routes utils tests
|
| 23 |
- run: pytest -q
|
|
|
|
| 16 |
cache: pip
|
| 17 |
- run: python -m pip install -r requirements-test.txt
|
| 18 |
- run: python -m compileall -q .
|
| 19 |
+
- run: >-
|
| 20 |
+
python -m pyflakes app.py config.py gradio_ui.py
|
| 21 |
+
core gateway_mcp models routes utils tests
|
| 22 |
- run: >-
|
| 23 |
python -m pycodestyle --max-line-length=100 --ignore=E203,W503
|
| 24 |
+
app.py config.py gradio_ui.py core gateway_mcp models routes utils tests
|
| 25 |
- run: pytest -q
|
README.md
CHANGED
|
@@ -3,23 +3,25 @@ title: AI Gateway
|
|
| 3 |
emoji: 🚪
|
| 4 |
colorFrom: indigo
|
| 5 |
colorTo: purple
|
| 6 |
-
sdk:
|
| 7 |
-
|
|
|
|
|
|
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
# AI Gateway
|
| 12 |
|
| 13 |
AI Gateway is a single-worker FastAPI service for image, video, speech, music,
|
| 14 |
-
sound-effect, and transcription models. It is designed for a Hugging Face
|
| 15 |
-
Space
|
| 16 |
-
|
| 17 |
-
|
| 18 |
|
| 19 |
The application starts without downloading model weights. A model is downloaded
|
| 20 |
and loaded only when its endpoint receives its first queued job. One model can be
|
| 21 |
-
resident at a time
|
| 22 |
-
|
| 23 |
|
| 24 |
## Models
|
| 25 |
|
|
@@ -40,9 +42,9 @@ checkpoint.
|
|
| 40 |
## Architecture
|
| 41 |
|
| 42 |
```text
|
| 43 |
-
REST clients
|
| 44 |
-
|
|
| 45 |
-
+----------> FastAPI <-------+
|
| 46 |
|
|
| 47 |
+------------+------------+
|
| 48 |
| |
|
|
@@ -52,6 +54,8 @@ REST clients MCP clients
|
|
| 52 |
|
|
| 53 |
single-worker queue
|
| 54 |
|
|
|
|
|
|
|
|
| 55 |
singleton model loader
|
| 56 |
|
|
| 57 |
isolated FLUX / WAN / audio adapter
|
|
@@ -59,12 +63,12 @@ REST clients MCP clients
|
|
| 59 |
UUID output manager
|
| 60 |
```
|
| 61 |
|
| 62 |
-
REST
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
|
| 69 |
## Run locally
|
| 70 |
|
|
@@ -76,53 +80,38 @@ cp .env.example .env
|
|
| 76 |
python -m venv .venv
|
| 77 |
. .venv/bin/activate
|
| 78 |
pip install -r requirements.txt
|
| 79 |
-
|
| 80 |
```
|
| 81 |
|
| 82 |
Do not increase the worker count: serialization is process-local, so multiple
|
| 83 |
workers would allow concurrent model loading.
|
| 84 |
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
```bash
|
| 88 |
-
docker build -t ai-gateway .
|
| 89 |
-
docker run --rm --gpus all -p 7860:7860 \
|
| 90 |
-
-e HF_TOKEN="$HF_TOKEN" \
|
| 91 |
-
-v hf-cache:/data/.huggingface \
|
| 92 |
-
ai-gateway
|
| 93 |
-
```
|
| 94 |
-
|
| 95 |
-
The image starts with:
|
| 96 |
|
| 97 |
```text
|
| 98 |
uvicorn app:app --host 0.0.0.0 --port 7860 --workers 1 --proxy-headers
|
| 99 |
```
|
| 100 |
|
| 101 |
-
|
| 102 |
-
a model.
|
| 103 |
|
| 104 |
## Hugging Face Space deployment
|
| 105 |
|
| 106 |
-
1. Create a new Space with **
|
|
|
|
| 107 |
2. Push this directory to the Space repository.
|
| 108 |
-
3.
|
| 109 |
-
4.
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
### ZeroGPU limitation
|
| 115 |
|
| 116 |
-
|
| 117 |
-
the **Gradio SDK**, while this deployment is a **Docker SDK** Space. Those two
|
| 118 |
-
target requirements cannot be enabled simultaneously by Docker configuration.
|
| 119 |
-
For a normal Docker GPU Space, keep `ENABLE_ZEROGPU=false`.
|
| 120 |
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
|
| 127 |
## Configuration
|
| 128 |
|
|
@@ -141,9 +130,15 @@ variables.
|
|
| 141 |
| `API_KEY` | unset | Optional bearer or `X-API-Key` authentication |
|
| 142 |
| `MCP_API_KEY` | unset | Optional MCP-only `X-API-Key` credential |
|
| 143 |
| `MCP_BEARER_TOKEN` | unset | Optional MCP-only Bearer credential |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
| `HF_TOKEN` | unset | Hugging Face access token |
|
| 145 |
-
| `OUTPUT_FOLDER` | `output` | Generated assets
|
| 146 |
-
| `TMP_FOLDER` | `tmp` | Request-scoped uploads
|
| 147 |
| `FLUX_MODEL_ID` | `black-forest-labs/FLUX.2-klein-4B` | Image checkpoint |
|
| 148 |
| `WAN_MODEL_ID` | `Wan-AI/Wan2.2-I2V-A14B-Diffusers` | Video checkpoint |
|
| 149 |
| `KOKORO_MODEL_ID` | `hexgrad/Kokoro-82M` | Speech checkpoint |
|
|
@@ -156,7 +151,8 @@ If `API_KEY` is configured, send either `X-API-Key: ...` or
|
|
| 156 |
generated `/output` assets are protected. `API_KEY` authorizes both REST and MCP.
|
| 157 |
The two `MCP_*` credentials authorize `/mcp` only and do not broaden REST access.
|
| 158 |
Never put these values in source control; configure them as Hugging Face Space
|
| 159 |
-
secrets.
|
|
|
|
| 160 |
|
| 161 |
## MCP
|
| 162 |
|
|
@@ -347,9 +343,9 @@ serialization, and workflow order without downloading weights:
|
|
| 347 |
```bash
|
| 348 |
pip install -r requirements-test.txt
|
| 349 |
pytest -q
|
| 350 |
-
python -m pyflakes app.py config.py core gateway_mcp models routes utils tests
|
| 351 |
python -m pycodestyle --max-line-length=100 --ignore=E203,W503 \
|
| 352 |
-
app.py config.py core gateway_mcp models routes utils tests
|
| 353 |
```
|
| 354 |
|
| 355 |
## Troubleshooting
|
|
@@ -361,9 +357,10 @@ python -m pycodestyle --max-line-length=100 --ignore=E203,W503 \
|
|
| 361 |
- `queue_full` or `queue_timeout`: reduce caller concurrency or raise `MAX_QUEUE` /
|
| 362 |
`JOB_TIMEOUT_SECONDS`. Do not add Uvicorn workers, because serialization is
|
| 363 |
process-local.
|
| 364 |
-
- `
|
| 365 |
-
|
| 366 |
-
|
|
|
|
| 367 |
- `model_load_failed`: verify model IDs, model licenses, outbound Hub access, and
|
| 368 |
`HF_TOKEN` for gated repositories.
|
| 369 |
- `invalid_file_path`: video/transcription MCP inputs accept only existing asset paths
|
|
|
|
| 3 |
emoji: 🚪
|
| 4 |
colorFrom: indigo
|
| 5 |
colorTo: purple
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: 6.20.0
|
| 8 |
+
python_version: 3.11
|
| 9 |
+
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
---
|
| 12 |
|
| 13 |
# AI Gateway
|
| 14 |
|
| 15 |
AI Gateway is a single-worker FastAPI service for image, video, speech, music,
|
| 16 |
+
sound-effect, and transcription models. It is designed for a Hugging Face Gradio
|
| 17 |
+
Space with ZeroGPU support. It provides a Gradio interface at `/ui`, REST endpoints
|
| 18 |
+
for n8n and application backends, and Model Context Protocol (MCP) tools for
|
| 19 |
+
ChatGPT, Claude, Cursor, Codex, and other MCP clients.
|
| 20 |
|
| 21 |
The application starts without downloading model weights. A model is downloaded
|
| 22 |
and loaded only when its endpoint receives its first queued job. One model can be
|
| 23 |
+
resident at a time on a regular host. In ZeroGPU workers, weights and Torch caches
|
| 24 |
+
are fully released after every scheduled model stage.
|
| 25 |
|
| 26 |
## Models
|
| 27 |
|
|
|
|
| 42 |
## Architecture
|
| 43 |
|
| 44 |
```text
|
| 45 |
+
Gradio UI REST clients MCP clients
|
| 46 |
+
| | |
|
| 47 |
+
+--------------------> FastAPI <-----------------+
|
| 48 |
|
|
| 49 |
+------------+------------+
|
| 50 |
| |
|
|
|
|
| 54 |
|
|
| 55 |
single-worker queue
|
| 56 |
|
|
| 57 |
+
spaces.GPU ZeroGPU scheduler
|
| 58 |
+
|
|
| 59 |
singleton model loader
|
| 60 |
|
|
| 61 |
isolated FLUX / WAN / audio adapter
|
|
|
|
| 63 |
UUID output manager
|
| 64 |
```
|
| 65 |
|
| 66 |
+
Gradio, REST, and MCP contain no inference implementation. All three call the same
|
| 67 |
+
`core/manager.py` service, queue, serializable command executor, loader, and workflow.
|
| 68 |
+
The queue captures request context so ZeroGPU can apply the originating Hugging Face
|
| 69 |
+
quota. WAN requests use the `xlarge` ZeroGPU tier; other models use `large`. A full
|
| 70 |
+
workflow remains one non-interleavable gateway job while each model stage receives
|
| 71 |
+
its own GPU allocation. Model weights are fully unloaded after every ZeroGPU stage.
|
| 72 |
|
| 73 |
## Run locally
|
| 74 |
|
|
|
|
| 80 |
python -m venv .venv
|
| 81 |
. .venv/bin/activate
|
| 82 |
pip install -r requirements.txt
|
| 83 |
+
python app.py
|
| 84 |
```
|
| 85 |
|
| 86 |
Do not increase the worker count: serialization is process-local, so multiple
|
| 87 |
workers would allow concurrent model loading.
|
| 88 |
|
| 89 |
+
The local startup command is equivalent to:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
```text
|
| 92 |
uvicorn app:app --host 0.0.0.0 --port 7860 --workers 1 --proxy-headers
|
| 93 |
```
|
| 94 |
|
| 95 |
+
`GET /health` does not download or load model weights.
|
|
|
|
| 96 |
|
| 97 |
## Hugging Face Space deployment
|
| 98 |
|
| 99 |
+
1. Create a new Space with **Gradio** as the SDK, or push this repository and let
|
| 100 |
+
the README metadata configure it.
|
| 101 |
2. Push this directory to the Space repository.
|
| 102 |
+
3. Select **ZeroGPU** as the Space hardware.
|
| 103 |
+
4. Add `HF_TOKEN`, `API_KEY`, and MCP credentials as Space secrets when needed.
|
| 104 |
+
5. `packages.txt` installs FFmpeg, espeak-ng, libsndfile, and Git.
|
| 105 |
+
6. Attach persistent storage and set `OUTPUT_FOLDER=/data/output` if generated
|
| 106 |
+
assets must survive restarts.
|
|
|
|
|
|
|
| 107 |
|
| 108 |
+
### ZeroGPU execution
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
+
Top-level `spaces.GPU` functions receive pickle-safe inference commands rather than
|
| 111 |
+
closures. This is required because ZeroGPU runs allocated work in forked processes.
|
| 112 |
+
Successes and domain errors are serialized back to the parent, where REST and MCP
|
| 113 |
+
retain their normal response contracts. On a normal CPU/GPU machine the same
|
| 114 |
+
decorators become direct calls, so no alternate inference implementation is needed.
|
| 115 |
|
| 116 |
## Configuration
|
| 117 |
|
|
|
|
| 130 |
| `API_KEY` | unset | Optional bearer or `X-API-Key` authentication |
|
| 131 |
| `MCP_API_KEY` | unset | Optional MCP-only `X-API-Key` credential |
|
| 132 |
| `MCP_BEARER_TOKEN` | unset | Optional MCP-only Bearer credential |
|
| 133 |
+
| `ZEROGPU_FLUX_DURATION` | `180` | FLUX allocation estimate in seconds |
|
| 134 |
+
| `ZEROGPU_WAN_DURATION` | `300` | WAN xlarge allocation estimate in seconds |
|
| 135 |
+
| `ZEROGPU_KOKORO_DURATION` | `90` | Kokoro allocation estimate in seconds |
|
| 136 |
+
| `ZEROGPU_MUSICGEN_DURATION` | `180` | MusicGen allocation estimate in seconds |
|
| 137 |
+
| `ZEROGPU_WHISPER_DURATION` | `180` | Whisper allocation estimate in seconds |
|
| 138 |
+
| `ZEROGPU_SFX_DURATION` | `180` | SFX allocation estimate in seconds |
|
| 139 |
| `HF_TOKEN` | unset | Hugging Face access token |
|
| 140 |
+
| `OUTPUT_FOLDER` | `output` | Generated assets; use `/data/output` with persistent storage |
|
| 141 |
+
| `TMP_FOLDER` | `tmp` | Request-scoped uploads |
|
| 142 |
| `FLUX_MODEL_ID` | `black-forest-labs/FLUX.2-klein-4B` | Image checkpoint |
|
| 143 |
| `WAN_MODEL_ID` | `Wan-AI/Wan2.2-I2V-A14B-Diffusers` | Video checkpoint |
|
| 144 |
| `KOKORO_MODEL_ID` | `hexgrad/Kokoro-82M` | Speech checkpoint |
|
|
|
|
| 151 |
generated `/output` assets are protected. `API_KEY` authorizes both REST and MCP.
|
| 152 |
The two `MCP_*` credentials authorize `/mcp` only and do not broaden REST access.
|
| 153 |
Never put these values in source control; configure them as Hugging Face Space
|
| 154 |
+
secrets. The Gradio UI at `/ui` remains public so Hugging Face can associate
|
| 155 |
+
ZeroGPU usage with the signed-in visitor.
|
| 156 |
|
| 157 |
## MCP
|
| 158 |
|
|
|
|
| 343 |
```bash
|
| 344 |
pip install -r requirements-test.txt
|
| 345 |
pytest -q
|
| 346 |
+
python -m pyflakes app.py config.py gradio_ui.py core gateway_mcp models routes utils tests
|
| 347 |
python -m pycodestyle --max-line-length=100 --ignore=E203,W503 \
|
| 348 |
+
app.py config.py gradio_ui.py core gateway_mcp models routes utils tests
|
| 349 |
```
|
| 350 |
|
| 351 |
## Troubleshooting
|
|
|
|
| 357 |
- `queue_full` or `queue_timeout`: reduce caller concurrency or raise `MAX_QUEUE` /
|
| 358 |
`JOB_TIMEOUT_SECONDS`. Do not add Uvicorn workers, because serialization is
|
| 359 |
process-local.
|
| 360 |
+
- `gpu_unavailable`: the ZeroGPU quota or scheduler is temporarily unavailable;
|
| 361 |
+
authenticate with Hugging Face for user quota or retry later.
|
| 362 |
+
- `out_of_memory`: reduce image/video settings. WAN uses the xlarge allocation tier,
|
| 363 |
+
but the selected checkpoint still has a high minimum memory requirement.
|
| 364 |
- `model_load_failed`: verify model IDs, model licenses, outbound Hub access, and
|
| 365 |
`HF_TOKEN` for gated repositories.
|
| 366 |
- `invalid_file_path`: video/transcription MCP inputs accept only existing asset paths
|
app.py
CHANGED
|
@@ -8,19 +8,23 @@ from contextlib import asynccontextmanager
|
|
| 8 |
from dataclasses import dataclass
|
| 9 |
from uuid import uuid4
|
| 10 |
|
|
|
|
| 11 |
from fastapi import FastAPI, HTTPException, Request
|
| 12 |
from fastapi.exceptions import RequestValidationError
|
| 13 |
from fastapi.responses import JSONResponse, RedirectResponse
|
| 14 |
from fastapi.staticfiles import StaticFiles
|
| 15 |
from fastmcp.utilities.lifespan import combine_lifespans
|
|
|
|
| 16 |
|
| 17 |
from config import Settings, get_settings
|
| 18 |
from core.errors import GatewayError
|
| 19 |
from core.loader import ModelLoader
|
| 20 |
from core.manager import AIService, TaskManager
|
|
|
|
| 21 |
from core.workflow import WorkflowService
|
| 22 |
from gateway_mcp.auth import AuthenticationError, authenticate_headers
|
| 23 |
from gateway_mcp.server import MCPServerBundle, create_mcp_server
|
|
|
|
| 24 |
from routes import audio, health, image, video, workflow
|
| 25 |
from routes.schemas import ErrorResponse
|
| 26 |
from utils.files import OutputManager
|
|
@@ -54,6 +58,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|
| 54 |
workflows = WorkflowService(resolved_settings, tasks, outputs)
|
| 55 |
ai = AIService(resolved_settings, tasks, loader, outputs, workflows)
|
| 56 |
mcp = create_mcp_server(ai)
|
|
|
|
| 57 |
services = GatewayServices(
|
| 58 |
resolved_settings, outputs, loader, tasks, workflows, ai, mcp
|
| 59 |
)
|
|
@@ -63,6 +68,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|
| 63 |
application.state.gateway = services
|
| 64 |
removed = outputs.cleanup_stale_tmp()
|
| 65 |
logger.info("gateway starting", extra={"stale_tmp_removed": removed})
|
|
|
|
| 66 |
await tasks.start()
|
| 67 |
try:
|
| 68 |
yield
|
|
@@ -85,10 +91,14 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|
| 85 |
request_id = uuid4().hex
|
| 86 |
request.state.request_id = request_id
|
| 87 |
token = request_id_context.set(request_id)
|
|
|
|
| 88 |
started = time.perf_counter()
|
| 89 |
try:
|
| 90 |
public_paths = {"/", "/health", "/docs", "/openapi.json", "/redoc"}
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
| 92 |
try:
|
| 93 |
identity = authenticate_headers(
|
| 94 |
request.headers,
|
|
@@ -123,6 +133,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|
| 123 |
},
|
| 124 |
)
|
| 125 |
request_id_context.reset(token)
|
|
|
|
| 126 |
response.headers["X-Request-ID"] = request_id
|
| 127 |
return response
|
| 128 |
|
|
@@ -186,14 +197,23 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|
| 186 |
|
| 187 |
@application.get("/", include_in_schema=False)
|
| 188 |
async def root() -> RedirectResponse:
|
| 189 |
-
return RedirectResponse(url="/
|
| 190 |
|
| 191 |
application.mount(
|
| 192 |
"/output",
|
| 193 |
StaticFiles(directory=resolved_settings.output_folder, check_dir=False),
|
| 194 |
name="output",
|
| 195 |
)
|
| 196 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
|
| 198 |
|
| 199 |
app = create_app()
|
|
@@ -203,4 +223,4 @@ if __name__ == "__main__":
|
|
| 203 |
import uvicorn
|
| 204 |
|
| 205 |
settings = get_settings()
|
| 206 |
-
uvicorn.run(
|
|
|
|
| 8 |
from dataclasses import dataclass
|
| 9 |
from uuid import uuid4
|
| 10 |
|
| 11 |
+
import gradio as gr
|
| 12 |
from fastapi import FastAPI, HTTPException, Request
|
| 13 |
from fastapi.exceptions import RequestValidationError
|
| 14 |
from fastapi.responses import JSONResponse, RedirectResponse
|
| 15 |
from fastapi.staticfiles import StaticFiles
|
| 16 |
from fastmcp.utilities.lifespan import combine_lifespans
|
| 17 |
+
from gradio.context import LocalContext
|
| 18 |
|
| 19 |
from config import Settings, get_settings
|
| 20 |
from core.errors import GatewayError
|
| 21 |
from core.loader import ModelLoader
|
| 22 |
from core.manager import AIService, TaskManager
|
| 23 |
+
from core.runtime import initialize_zerogpu
|
| 24 |
from core.workflow import WorkflowService
|
| 25 |
from gateway_mcp.auth import AuthenticationError, authenticate_headers
|
| 26 |
from gateway_mcp.server import MCPServerBundle, create_mcp_server
|
| 27 |
+
from gradio_ui import create_gradio_ui
|
| 28 |
from routes import audio, health, image, video, workflow
|
| 29 |
from routes.schemas import ErrorResponse
|
| 30 |
from utils.files import OutputManager
|
|
|
|
| 58 |
workflows = WorkflowService(resolved_settings, tasks, outputs)
|
| 59 |
ai = AIService(resolved_settings, tasks, loader, outputs, workflows)
|
| 60 |
mcp = create_mcp_server(ai)
|
| 61 |
+
gradio_ui = create_gradio_ui(ai)
|
| 62 |
services = GatewayServices(
|
| 63 |
resolved_settings, outputs, loader, tasks, workflows, ai, mcp
|
| 64 |
)
|
|
|
|
| 68 |
application.state.gateway = services
|
| 69 |
removed = outputs.cleanup_stale_tmp()
|
| 70 |
logger.info("gateway starting", extra={"stale_tmp_removed": removed})
|
| 71 |
+
initialize_zerogpu()
|
| 72 |
await tasks.start()
|
| 73 |
try:
|
| 74 |
yield
|
|
|
|
| 91 |
request_id = uuid4().hex
|
| 92 |
request.state.request_id = request_id
|
| 93 |
token = request_id_context.set(request_id)
|
| 94 |
+
gradio_token = LocalContext.request.set(gr.Request(request))
|
| 95 |
started = time.perf_counter()
|
| 96 |
try:
|
| 97 |
public_paths = {"/", "/health", "/docs", "/openapi.json", "/redoc"}
|
| 98 |
+
gradio_path = request.url.path == "/ui" or request.url.path.startswith(
|
| 99 |
+
"/ui/"
|
| 100 |
+
)
|
| 101 |
+
if request.url.path not in public_paths and not gradio_path:
|
| 102 |
try:
|
| 103 |
identity = authenticate_headers(
|
| 104 |
request.headers,
|
|
|
|
| 133 |
},
|
| 134 |
)
|
| 135 |
request_id_context.reset(token)
|
| 136 |
+
LocalContext.request.reset(gradio_token)
|
| 137 |
response.headers["X-Request-ID"] = request_id
|
| 138 |
return response
|
| 139 |
|
|
|
|
| 197 |
|
| 198 |
@application.get("/", include_in_schema=False)
|
| 199 |
async def root() -> RedirectResponse:
|
| 200 |
+
return RedirectResponse(url="/ui")
|
| 201 |
|
| 202 |
application.mount(
|
| 203 |
"/output",
|
| 204 |
StaticFiles(directory=resolved_settings.output_folder, check_dir=False),
|
| 205 |
name="output",
|
| 206 |
)
|
| 207 |
+
return gr.mount_gradio_app(
|
| 208 |
+
application,
|
| 209 |
+
gradio_ui,
|
| 210 |
+
path="/ui",
|
| 211 |
+
server_name=resolved_settings.host,
|
| 212 |
+
server_port=resolved_settings.port,
|
| 213 |
+
allowed_paths=[str(outputs.root)],
|
| 214 |
+
show_error=False,
|
| 215 |
+
max_file_size=f"{resolved_settings.max_upload_mb}mb",
|
| 216 |
+
)
|
| 217 |
|
| 218 |
|
| 219 |
app = create_app()
|
|
|
|
| 223 |
import uvicorn
|
| 224 |
|
| 225 |
settings = get_settings()
|
| 226 |
+
uvicorn.run(app, host=settings.host, port=settings.port, proxy_headers=True)
|
config.py
CHANGED
|
@@ -56,6 +56,13 @@ class Settings(BaseSettings):
|
|
| 56 |
sfx_duration: float = Field(default=5.0, ge=0.5, le=30.0)
|
| 57 |
sfx_steps: int = Field(default=50, ge=1, le=200)
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
flux_model_id: str = "black-forest-labs/FLUX.2-klein-4B"
|
| 60 |
flux_revision: str | None = None
|
| 61 |
wan_model_id: str = "Wan-AI/Wan2.2-I2V-A14B-Diffusers"
|
|
|
|
| 56 |
sfx_duration: float = Field(default=5.0, ge=0.5, le=30.0)
|
| 57 |
sfx_steps: int = Field(default=50, ge=1, le=200)
|
| 58 |
|
| 59 |
+
zerogpu_flux_duration: int = Field(default=180, ge=30, le=900)
|
| 60 |
+
zerogpu_wan_duration: int = Field(default=300, ge=30, le=900)
|
| 61 |
+
zerogpu_kokoro_duration: int = Field(default=90, ge=30, le=900)
|
| 62 |
+
zerogpu_musicgen_duration: int = Field(default=180, ge=30, le=900)
|
| 63 |
+
zerogpu_whisper_duration: int = Field(default=180, ge=30, le=900)
|
| 64 |
+
zerogpu_sfx_duration: int = Field(default=180, ge=30, le=900)
|
| 65 |
+
|
| 66 |
flux_model_id: str = "black-forest-labs/FLUX.2-klein-4B"
|
| 67 |
flux_revision: str | None = None
|
| 68 |
wan_model_id: str = "Wan-AI/Wan2.2-I2V-A14B-Diffusers"
|
core/executor.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Serializable inference dispatch across local, GPU, and ZeroGPU runtimes."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
import time
|
| 7 |
+
from dataclasses import dataclass
|
| 8 |
+
from typing import Any, Callable, Literal
|
| 9 |
+
|
| 10 |
+
from core.errors import GatewayError
|
| 11 |
+
from core.loader import ModelLoader
|
| 12 |
+
from core.runtime import inference_context, memory_stats
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _gpu_task(**options: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
| 19 |
+
"""Use the Space scheduler in deployment and a direct call in local tests."""
|
| 20 |
+
try:
|
| 21 |
+
import spaces
|
| 22 |
+
except ImportError:
|
| 23 |
+
return lambda function: function
|
| 24 |
+
return spaces.GPU(**options)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@dataclass(frozen=True, slots=True)
|
| 28 |
+
class InferenceCommand:
|
| 29 |
+
"""Pickle-safe description of one model method invocation."""
|
| 30 |
+
|
| 31 |
+
model_name: str
|
| 32 |
+
method_name: str
|
| 33 |
+
arguments: dict[str, Any]
|
| 34 |
+
request_id: str
|
| 35 |
+
duration_seconds: int
|
| 36 |
+
gpu_size: Literal["large", "xlarge"] = "large"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@dataclass(frozen=True, slots=True)
|
| 40 |
+
class CommandError:
|
| 41 |
+
"""Serializable error returned from a forked ZeroGPU worker."""
|
| 42 |
+
|
| 43 |
+
message: str
|
| 44 |
+
status_code: int
|
| 45 |
+
code: str
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@dataclass(frozen=True, slots=True)
|
| 49 |
+
class CommandResult:
|
| 50 |
+
"""Serializable success or failure from a GPU allocation."""
|
| 51 |
+
|
| 52 |
+
value: Any = None
|
| 53 |
+
error: CommandError | None = None
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _duration(command: InferenceCommand) -> int:
|
| 57 |
+
return command.duration_seconds
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _zero_gpu_active() -> bool:
|
| 61 |
+
try:
|
| 62 |
+
from spaces.config import Config
|
| 63 |
+
except ImportError:
|
| 64 |
+
return False
|
| 65 |
+
return bool(Config.zero_gpu)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _invoke(command: InferenceCommand) -> CommandResult:
|
| 69 |
+
"""Execute one command inside an allocated device process."""
|
| 70 |
+
loader = ModelLoader()
|
| 71 |
+
started = time.perf_counter()
|
| 72 |
+
try:
|
| 73 |
+
with loader.use_model(command.model_name) as model:
|
| 74 |
+
method = getattr(model, command.method_name, None)
|
| 75 |
+
if not callable(method):
|
| 76 |
+
raise RuntimeError(
|
| 77 |
+
f"Model {command.model_name} does not implement "
|
| 78 |
+
f"{command.method_name}"
|
| 79 |
+
)
|
| 80 |
+
with inference_context(loader.settings, loader.device):
|
| 81 |
+
value = method(**command.arguments)
|
| 82 |
+
except GatewayError as exc:
|
| 83 |
+
return CommandResult(
|
| 84 |
+
error=CommandError(exc.message, exc.status_code, exc.code)
|
| 85 |
+
)
|
| 86 |
+
except Exception as exc:
|
| 87 |
+
logger.exception(
|
| 88 |
+
"model inference failed",
|
| 89 |
+
extra={
|
| 90 |
+
"model": command.model_name,
|
| 91 |
+
"request_id": command.request_id,
|
| 92 |
+
**memory_stats(),
|
| 93 |
+
},
|
| 94 |
+
)
|
| 95 |
+
message = str(exc)
|
| 96 |
+
if "out of memory" in message.lower():
|
| 97 |
+
error = CommandError(
|
| 98 |
+
f"{command.model_name} ran out of memory",
|
| 99 |
+
507,
|
| 100 |
+
"out_of_memory",
|
| 101 |
+
)
|
| 102 |
+
elif isinstance(exc, OSError):
|
| 103 |
+
error = CommandError(
|
| 104 |
+
f"File operation failed during {command.model_name} inference",
|
| 105 |
+
500,
|
| 106 |
+
"file_error",
|
| 107 |
+
)
|
| 108 |
+
else:
|
| 109 |
+
error = CommandError(
|
| 110 |
+
f"{command.model_name} inference failed: {message}",
|
| 111 |
+
500,
|
| 112 |
+
"inference_failed",
|
| 113 |
+
)
|
| 114 |
+
return CommandResult(error=error)
|
| 115 |
+
finally:
|
| 116 |
+
if _zero_gpu_active():
|
| 117 |
+
loader.close()
|
| 118 |
+
|
| 119 |
+
logger.info(
|
| 120 |
+
"model inference completed",
|
| 121 |
+
extra={
|
| 122 |
+
"model": command.model_name,
|
| 123 |
+
"request_id": command.request_id,
|
| 124 |
+
"execution_time": round(time.perf_counter() - started, 3),
|
| 125 |
+
**memory_stats(),
|
| 126 |
+
},
|
| 127 |
+
)
|
| 128 |
+
return CommandResult(value=value)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
@_gpu_task(duration=_duration, size="large")
|
| 132 |
+
def _invoke_large(command: InferenceCommand) -> CommandResult:
|
| 133 |
+
return _invoke(command)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
@_gpu_task(duration=_duration, size="xlarge")
|
| 137 |
+
def _invoke_xlarge(command: InferenceCommand) -> CommandResult:
|
| 138 |
+
return _invoke(command)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def execute_inference(command: InferenceCommand) -> Any:
|
| 142 |
+
"""Acquire the requested ZeroGPU tier and restore domain errors in the parent."""
|
| 143 |
+
try:
|
| 144 |
+
result = (
|
| 145 |
+
_invoke_xlarge(command)
|
| 146 |
+
if command.gpu_size == "xlarge"
|
| 147 |
+
else _invoke_large(command)
|
| 148 |
+
)
|
| 149 |
+
except Exception as exc:
|
| 150 |
+
message = str(exc)
|
| 151 |
+
normalized = message.lower()
|
| 152 |
+
if "quota" in normalized or "no gpu" in normalized or "zerogpu" in normalized:
|
| 153 |
+
raise GatewayError(
|
| 154 |
+
"ZeroGPU is unavailable or its quota was exceeded",
|
| 155 |
+
status_code=503,
|
| 156 |
+
code="gpu_unavailable",
|
| 157 |
+
) from exc
|
| 158 |
+
raise GatewayError(
|
| 159 |
+
"ZeroGPU scheduling failed",
|
| 160 |
+
status_code=503,
|
| 161 |
+
code="gpu_scheduling_failed",
|
| 162 |
+
) from exc
|
| 163 |
+
if result.error is not None:
|
| 164 |
+
raise GatewayError(
|
| 165 |
+
result.error.message,
|
| 166 |
+
status_code=result.error.status_code,
|
| 167 |
+
code=result.error.code,
|
| 168 |
+
)
|
| 169 |
+
return result.value
|
core/manager.py
CHANGED
|
@@ -2,16 +2,15 @@
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
-
import logging
|
| 6 |
-
import time
|
| 7 |
from pathlib import Path
|
| 8 |
-
from typing import
|
| 9 |
|
| 10 |
from config import Settings
|
| 11 |
-
from core.errors import GatewayError
|
|
|
|
| 12 |
from core.loader import ModelLoader
|
| 13 |
from core.queue import InferenceQueue
|
| 14 |
-
from core.runtime import gpu_available,
|
| 15 |
from core.schemas import (
|
| 16 |
EpisodeAssets,
|
| 17 |
ImageAsset,
|
|
@@ -27,21 +26,11 @@ from core.schemas import (
|
|
| 27 |
VideoRequest,
|
| 28 |
WorkflowRequest,
|
| 29 |
)
|
| 30 |
-
from models.base import ModelAdapter
|
| 31 |
from utils.files import OutputManager
|
| 32 |
from utils.validation import validate_image_dimensions
|
| 33 |
|
| 34 |
-
if TYPE_CHECKING:
|
| 35 |
-
from models.flux import FluxModel
|
| 36 |
-
from models.kokoro import KokoroModel
|
| 37 |
-
from models.musicgen import MusicGenModel
|
| 38 |
-
from models.sfx import SFXModel
|
| 39 |
-
from models.wan import WanModel
|
| 40 |
-
from models.whisper import WhisperModel
|
| 41 |
-
|
| 42 |
|
| 43 |
T = TypeVar("T")
|
| 44 |
-
logger = logging.getLogger(__name__)
|
| 45 |
|
| 46 |
|
| 47 |
class WorkflowRunner(Protocol):
|
|
@@ -69,15 +58,13 @@ class TaskManager:
|
|
| 69 |
|
| 70 |
async def run(
|
| 71 |
self,
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
action: Callable[[ModelAdapter], T],
|
| 75 |
-
) -> T:
|
| 76 |
"""Queue one model action."""
|
| 77 |
return await self.queue.submit(
|
| 78 |
-
request_id,
|
| 79 |
-
model_name,
|
| 80 |
-
lambda: self.invoke_direct(
|
| 81 |
)
|
| 82 |
|
| 83 |
async def run_exclusive(
|
|
@@ -86,45 +73,10 @@ class TaskManager:
|
|
| 86 |
"""Queue a compound operation as one non-interleavable job."""
|
| 87 |
return await self.queue.submit(request_id, label, action)
|
| 88 |
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
action: Callable[[ModelAdapter], T],
|
| 94 |
-
) -> T:
|
| 95 |
-
"""Invoke a model inside the queue worker; workflows reuse this method."""
|
| 96 |
-
started = time.perf_counter()
|
| 97 |
-
try:
|
| 98 |
-
with self.loader.use_model(model_name) as model:
|
| 99 |
-
with inference_context(self.settings, self.loader.device):
|
| 100 |
-
result = action(model)
|
| 101 |
-
except GatewayError:
|
| 102 |
-
raise
|
| 103 |
-
except Exception as exc:
|
| 104 |
-
logger.exception(
|
| 105 |
-
"model inference failed",
|
| 106 |
-
extra={"model": model_name, "request_id": request_id, **memory_stats()},
|
| 107 |
-
)
|
| 108 |
-
message = str(exc)
|
| 109 |
-
if "out of memory" in message.lower():
|
| 110 |
-
raise OutOfMemoryError(model_name) from exc
|
| 111 |
-
if isinstance(exc, OSError):
|
| 112 |
-
raise GatewayError(
|
| 113 |
-
f"File operation failed during {model_name} inference",
|
| 114 |
-
status_code=500,
|
| 115 |
-
code="file_error",
|
| 116 |
-
) from exc
|
| 117 |
-
raise InferenceError(model_name, message) from exc
|
| 118 |
-
logger.info(
|
| 119 |
-
"model inference completed",
|
| 120 |
-
extra={
|
| 121 |
-
"model": model_name,
|
| 122 |
-
"request_id": request_id,
|
| 123 |
-
"execution_time": round(time.perf_counter() - started, 3),
|
| 124 |
-
**memory_stats(),
|
| 125 |
-
},
|
| 126 |
-
)
|
| 127 |
-
return result
|
| 128 |
|
| 129 |
|
| 130 |
class AIService:
|
|
@@ -170,16 +122,20 @@ class AIService:
|
|
| 170 |
target = self.outputs.allocate("images")
|
| 171 |
try:
|
| 172 |
await self.tasks.run(
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
),
|
| 184 |
)
|
| 185 |
except Exception:
|
|
@@ -203,18 +159,23 @@ class AIService:
|
|
| 203 |
target = self.outputs.allocate("videos")
|
| 204 |
try:
|
| 205 |
await self.tasks.run(
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
),
|
| 219 |
)
|
| 220 |
except Exception:
|
|
@@ -228,13 +189,17 @@ class AIService:
|
|
| 228 |
target = self.outputs.allocate("audio")
|
| 229 |
try:
|
| 230 |
await self.tasks.run(
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
),
|
| 239 |
)
|
| 240 |
except Exception:
|
|
@@ -255,14 +220,18 @@ class AIService:
|
|
| 255 |
target = self.outputs.allocate("music")
|
| 256 |
try:
|
| 257 |
await self.tasks.run(
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
),
|
| 267 |
)
|
| 268 |
except Exception:
|
|
@@ -279,14 +248,18 @@ class AIService:
|
|
| 279 |
target = self.outputs.allocate("audio")
|
| 280 |
try:
|
| 281 |
await self.tasks.run(
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
),
|
| 291 |
)
|
| 292 |
except Exception:
|
|
@@ -306,13 +279,17 @@ class AIService:
|
|
| 306 |
subtitle = self.outputs.allocate("subtitles")
|
| 307 |
try:
|
| 308 |
result = await self.tasks.run(
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 316 |
),
|
| 317 |
)
|
| 318 |
except Exception:
|
|
@@ -373,7 +350,7 @@ class AIService:
|
|
| 373 |
"""Return operational metadata without loading a model."""
|
| 374 |
return {
|
| 375 |
"version": self.settings.app_version,
|
| 376 |
-
"device": self.loader.device,
|
| 377 |
"gpu": "available" if gpu_available() else "unavailable",
|
| 378 |
"memory": memory_stats(),
|
| 379 |
"available_endpoints": [*self.rest_endpoints, "/mcp"],
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
|
|
|
| 5 |
from pathlib import Path
|
| 6 |
+
from typing import Any, Callable, Literal, Protocol, TypeVar
|
| 7 |
|
| 8 |
from config import Settings
|
| 9 |
+
from core.errors import GatewayError
|
| 10 |
+
from core.executor import InferenceCommand, execute_inference
|
| 11 |
from core.loader import ModelLoader
|
| 12 |
from core.queue import InferenceQueue
|
| 13 |
+
from core.runtime import gpu_available, memory_stats, zerogpu_enabled
|
| 14 |
from core.schemas import (
|
| 15 |
EpisodeAssets,
|
| 16 |
ImageAsset,
|
|
|
|
| 26 |
VideoRequest,
|
| 27 |
WorkflowRequest,
|
| 28 |
)
|
|
|
|
| 29 |
from utils.files import OutputManager
|
| 30 |
from utils.validation import validate_image_dimensions
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
T = TypeVar("T")
|
|
|
|
| 34 |
|
| 35 |
|
| 36 |
class WorkflowRunner(Protocol):
|
|
|
|
| 58 |
|
| 59 |
async def run(
|
| 60 |
self,
|
| 61 |
+
command: InferenceCommand,
|
| 62 |
+
) -> Any:
|
|
|
|
|
|
|
| 63 |
"""Queue one model action."""
|
| 64 |
return await self.queue.submit(
|
| 65 |
+
command.request_id,
|
| 66 |
+
command.model_name,
|
| 67 |
+
lambda: self.invoke_direct(command),
|
| 68 |
)
|
| 69 |
|
| 70 |
async def run_exclusive(
|
|
|
|
| 73 |
"""Queue a compound operation as one non-interleavable job."""
|
| 74 |
return await self.queue.submit(request_id, label, action)
|
| 75 |
|
| 76 |
+
@staticmethod
|
| 77 |
+
def invoke_direct(command: InferenceCommand) -> Any:
|
| 78 |
+
"""Invoke a serializable command; workflows reuse this method."""
|
| 79 |
+
return execute_inference(command)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
|
| 81 |
|
| 82 |
class AIService:
|
|
|
|
| 122 |
target = self.outputs.allocate("images")
|
| 123 |
try:
|
| 124 |
await self.tasks.run(
|
| 125 |
+
InferenceCommand(
|
| 126 |
+
model_name="flux",
|
| 127 |
+
method_name="generate",
|
| 128 |
+
arguments={
|
| 129 |
+
"prompt": payload.prompt,
|
| 130 |
+
"output_path": target,
|
| 131 |
+
"width": width,
|
| 132 |
+
"height": height,
|
| 133 |
+
"steps": steps,
|
| 134 |
+
"seed": payload.seed,
|
| 135 |
+
"guidance_scale": guidance_scale,
|
| 136 |
+
},
|
| 137 |
+
request_id=request_id,
|
| 138 |
+
duration_seconds=self.settings.zerogpu_flux_duration,
|
| 139 |
),
|
| 140 |
)
|
| 141 |
except Exception:
|
|
|
|
| 159 |
target = self.outputs.allocate("videos")
|
| 160 |
try:
|
| 161 |
await self.tasks.run(
|
| 162 |
+
InferenceCommand(
|
| 163 |
+
model_name="wan",
|
| 164 |
+
method_name="generate",
|
| 165 |
+
arguments={
|
| 166 |
+
"image_path": source,
|
| 167 |
+
"prompt": payload.prompt,
|
| 168 |
+
"negative_prompt": payload.negative_prompt,
|
| 169 |
+
"output_path": target,
|
| 170 |
+
"steps": steps,
|
| 171 |
+
"frames": frames,
|
| 172 |
+
"fps": fps,
|
| 173 |
+
"seed": payload.seed,
|
| 174 |
+
"guidance_scale": guidance_scale,
|
| 175 |
+
},
|
| 176 |
+
request_id=request_id,
|
| 177 |
+
duration_seconds=self.settings.zerogpu_wan_duration,
|
| 178 |
+
gpu_size="xlarge",
|
| 179 |
),
|
| 180 |
)
|
| 181 |
except Exception:
|
|
|
|
| 189 |
target = self.outputs.allocate("audio")
|
| 190 |
try:
|
| 191 |
await self.tasks.run(
|
| 192 |
+
InferenceCommand(
|
| 193 |
+
model_name="kokoro",
|
| 194 |
+
method_name="synthesize",
|
| 195 |
+
arguments={
|
| 196 |
+
"text": payload.text,
|
| 197 |
+
"voice": voice,
|
| 198 |
+
"speed": payload.speed,
|
| 199 |
+
"output_path": target,
|
| 200 |
+
},
|
| 201 |
+
request_id=request_id,
|
| 202 |
+
duration_seconds=self.settings.zerogpu_kokoro_duration,
|
| 203 |
),
|
| 204 |
)
|
| 205 |
except Exception:
|
|
|
|
| 220 |
target = self.outputs.allocate("music")
|
| 221 |
try:
|
| 222 |
await self.tasks.run(
|
| 223 |
+
InferenceCommand(
|
| 224 |
+
model_name="musicgen",
|
| 225 |
+
method_name="generate",
|
| 226 |
+
arguments={
|
| 227 |
+
"prompt": payload.prompt,
|
| 228 |
+
"duration": duration,
|
| 229 |
+
"guidance_scale": guidance_scale,
|
| 230 |
+
"seed": payload.seed,
|
| 231 |
+
"output_path": target,
|
| 232 |
+
},
|
| 233 |
+
request_id=request_id,
|
| 234 |
+
duration_seconds=self.settings.zerogpu_musicgen_duration,
|
| 235 |
),
|
| 236 |
)
|
| 237 |
except Exception:
|
|
|
|
| 248 |
target = self.outputs.allocate("audio")
|
| 249 |
try:
|
| 250 |
await self.tasks.run(
|
| 251 |
+
InferenceCommand(
|
| 252 |
+
model_name="sfx",
|
| 253 |
+
method_name="generate",
|
| 254 |
+
arguments={
|
| 255 |
+
"prompt": payload.prompt,
|
| 256 |
+
"duration": duration,
|
| 257 |
+
"steps": steps,
|
| 258 |
+
"seed": payload.seed,
|
| 259 |
+
"output_path": target,
|
| 260 |
+
},
|
| 261 |
+
request_id=request_id,
|
| 262 |
+
duration_seconds=self.settings.zerogpu_sfx_duration,
|
| 263 |
),
|
| 264 |
)
|
| 265 |
except Exception:
|
|
|
|
| 279 |
subtitle = self.outputs.allocate("subtitles")
|
| 280 |
try:
|
| 281 |
result = await self.tasks.run(
|
| 282 |
+
InferenceCommand(
|
| 283 |
+
model_name="whisper",
|
| 284 |
+
method_name="transcribe",
|
| 285 |
+
arguments={
|
| 286 |
+
"source": source,
|
| 287 |
+
"subtitle_path": subtitle,
|
| 288 |
+
"language": language,
|
| 289 |
+
"task": task,
|
| 290 |
+
},
|
| 291 |
+
request_id=request_id,
|
| 292 |
+
duration_seconds=self.settings.zerogpu_whisper_duration,
|
| 293 |
),
|
| 294 |
)
|
| 295 |
except Exception:
|
|
|
|
| 350 |
"""Return operational metadata without loading a model."""
|
| 351 |
return {
|
| 352 |
"version": self.settings.app_version,
|
| 353 |
+
"device": "zerogpu" if zerogpu_enabled() else self.loader.device,
|
| 354 |
"gpu": "available" if gpu_available() else "unavailable",
|
| 355 |
"memory": memory_stats(),
|
| 356 |
"available_endpoints": [*self.rest_endpoints, "/mcp"],
|
core/queue.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import asyncio
|
|
|
|
| 6 |
from dataclasses import dataclass
|
| 7 |
from typing import Any, Callable, Generic, TypeVar
|
| 8 |
|
|
@@ -17,6 +18,7 @@ class QueueJob(Generic[T]):
|
|
| 17 |
request_id: str
|
| 18 |
label: str
|
| 19 |
operation: Callable[[], T]
|
|
|
|
| 20 |
future: asyncio.Future[T]
|
| 21 |
started: asyncio.Future[None]
|
| 22 |
cancelled: bool = False
|
|
@@ -61,6 +63,7 @@ class InferenceQueue:
|
|
| 61 |
request_id=request_id,
|
| 62 |
label=label,
|
| 63 |
operation=operation,
|
|
|
|
| 64 |
future=future,
|
| 65 |
started=started,
|
| 66 |
)
|
|
@@ -110,7 +113,7 @@ class InferenceQueue:
|
|
| 110 |
if not job.started.done():
|
| 111 |
job.started.set_result(None)
|
| 112 |
try:
|
| 113 |
-
result = await asyncio.to_thread(job.operation)
|
| 114 |
except Exception as exc:
|
| 115 |
if not job.future.done():
|
| 116 |
job.future.set_exception(exc)
|
|
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import asyncio
|
| 6 |
+
from contextvars import Context, copy_context
|
| 7 |
from dataclasses import dataclass
|
| 8 |
from typing import Any, Callable, Generic, TypeVar
|
| 9 |
|
|
|
|
| 18 |
request_id: str
|
| 19 |
label: str
|
| 20 |
operation: Callable[[], T]
|
| 21 |
+
context: Context
|
| 22 |
future: asyncio.Future[T]
|
| 23 |
started: asyncio.Future[None]
|
| 24 |
cancelled: bool = False
|
|
|
|
| 63 |
request_id=request_id,
|
| 64 |
label=label,
|
| 65 |
operation=operation,
|
| 66 |
+
context=copy_context(),
|
| 67 |
future=future,
|
| 68 |
started=started,
|
| 69 |
)
|
|
|
|
| 113 |
if not job.started.done():
|
| 114 |
job.started.set_result(None)
|
| 115 |
try:
|
| 116 |
+
result = await asyncio.to_thread(job.context.run, job.operation)
|
| 117 |
except Exception as exc:
|
| 118 |
if not job.future.done():
|
| 119 |
job.future.set_exception(exc)
|
core/runtime.py
CHANGED
|
@@ -57,7 +57,9 @@ def preferred_dtype(device: str, mixed_precision: bool = True) -> Any:
|
|
| 57 |
|
| 58 |
|
| 59 |
def gpu_available() -> bool:
|
| 60 |
-
"""Return
|
|
|
|
|
|
|
| 61 |
try:
|
| 62 |
torch = import_torch()
|
| 63 |
except RuntimeError:
|
|
@@ -65,6 +67,24 @@ def gpu_available() -> bool:
|
|
| 65 |
return bool(torch.cuda.is_available())
|
| 66 |
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
@contextmanager
|
| 69 |
def inference_context(settings: Settings, device: str) -> Iterator[None]:
|
| 70 |
"""Disable autograd and enable CUDA mixed precision when configured."""
|
|
|
|
| 57 |
|
| 58 |
|
| 59 |
def gpu_available() -> bool:
|
| 60 |
+
"""Return scheduled ZeroGPU or directly attached CUDA availability."""
|
| 61 |
+
if zerogpu_enabled():
|
| 62 |
+
return True
|
| 63 |
try:
|
| 64 |
torch = import_torch()
|
| 65 |
except RuntimeError:
|
|
|
|
| 67 |
return bool(torch.cuda.is_available())
|
| 68 |
|
| 69 |
|
| 70 |
+
def zerogpu_enabled() -> bool:
|
| 71 |
+
"""Return whether this process is running in a Hugging Face ZeroGPU Space."""
|
| 72 |
+
try:
|
| 73 |
+
from spaces.config import Config
|
| 74 |
+
except ImportError:
|
| 75 |
+
return False
|
| 76 |
+
return bool(Config.zero_gpu)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def initialize_zerogpu() -> None:
|
| 80 |
+
"""Run the scheduler startup hook when Gradio is mounted into FastAPI."""
|
| 81 |
+
if not zerogpu_enabled():
|
| 82 |
+
return
|
| 83 |
+
from spaces.zero import startup
|
| 84 |
+
|
| 85 |
+
startup()
|
| 86 |
+
|
| 87 |
+
|
| 88 |
@contextmanager
|
| 89 |
def inference_context(settings: Settings, device: str) -> Iterator[None]:
|
| 90 |
"""Disable autograd and enable CUDA mixed precision when configured."""
|
core/workflow.py
CHANGED
|
@@ -3,20 +3,13 @@
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
from pathlib import Path
|
| 6 |
-
from typing import TYPE_CHECKING, cast
|
| 7 |
|
| 8 |
from config import Settings
|
|
|
|
| 9 |
from core.manager import TaskManager
|
| 10 |
from core.schemas import WorkflowRequest
|
| 11 |
from utils.files import OutputManager
|
| 12 |
|
| 13 |
-
if TYPE_CHECKING:
|
| 14 |
-
from models.flux import FluxModel
|
| 15 |
-
from models.kokoro import KokoroModel
|
| 16 |
-
from models.musicgen import MusicGenModel
|
| 17 |
-
from models.wan import WanModel
|
| 18 |
-
from models.whisper import WhisperModel
|
| 19 |
-
|
| 20 |
|
| 21 |
class WorkflowService:
|
| 22 |
"""Run image, voice, music, video, and subtitle generation in order."""
|
|
@@ -43,26 +36,34 @@ class WorkflowService:
|
|
| 43 |
|
| 44 |
def execute() -> dict[str, Path]:
|
| 45 |
self.tasks.invoke_direct(
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
),
|
| 57 |
)
|
| 58 |
self.tasks.invoke_direct(
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
),
|
| 67 |
)
|
| 68 |
words = len(payload.script.split())
|
|
@@ -71,39 +72,52 @@ class WorkflowService:
|
|
| 71 |
f"cinematic instrumental background score for {payload.title}, no vocals"
|
| 72 |
)
|
| 73 |
self.tasks.invoke_direct(
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
),
|
| 83 |
)
|
| 84 |
self.tasks.invoke_direct(
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
),
|
| 98 |
)
|
| 99 |
self.tasks.invoke_direct(
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
),
|
| 108 |
)
|
| 109 |
return targets
|
|
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
from pathlib import Path
|
|
|
|
| 6 |
|
| 7 |
from config import Settings
|
| 8 |
+
from core.executor import InferenceCommand
|
| 9 |
from core.manager import TaskManager
|
| 10 |
from core.schemas import WorkflowRequest
|
| 11 |
from utils.files import OutputManager
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
class WorkflowService:
|
| 15 |
"""Run image, voice, music, video, and subtitle generation in order."""
|
|
|
|
| 36 |
|
| 37 |
def execute() -> dict[str, Path]:
|
| 38 |
self.tasks.invoke_direct(
|
| 39 |
+
InferenceCommand(
|
| 40 |
+
model_name="flux",
|
| 41 |
+
method_name="generate",
|
| 42 |
+
arguments={
|
| 43 |
+
"prompt": payload.image_prompt,
|
| 44 |
+
"output_path": targets["image"],
|
| 45 |
+
"width": width,
|
| 46 |
+
"height": height,
|
| 47 |
+
"steps": self.settings.image_steps,
|
| 48 |
+
"seed": payload.seed,
|
| 49 |
+
"guidance_scale": self.settings.flux_guidance_scale,
|
| 50 |
+
},
|
| 51 |
+
request_id=request_id,
|
| 52 |
+
duration_seconds=self.settings.zerogpu_flux_duration,
|
| 53 |
),
|
| 54 |
)
|
| 55 |
self.tasks.invoke_direct(
|
| 56 |
+
InferenceCommand(
|
| 57 |
+
model_name="kokoro",
|
| 58 |
+
method_name="synthesize",
|
| 59 |
+
arguments={
|
| 60 |
+
"text": payload.script,
|
| 61 |
+
"voice": voice,
|
| 62 |
+
"speed": 1.0,
|
| 63 |
+
"output_path": targets["voice"],
|
| 64 |
+
},
|
| 65 |
+
request_id=request_id,
|
| 66 |
+
duration_seconds=self.settings.zerogpu_kokoro_duration,
|
| 67 |
),
|
| 68 |
)
|
| 69 |
words = len(payload.script.split())
|
|
|
|
| 72 |
f"cinematic instrumental background score for {payload.title}, no vocals"
|
| 73 |
)
|
| 74 |
self.tasks.invoke_direct(
|
| 75 |
+
InferenceCommand(
|
| 76 |
+
model_name="musicgen",
|
| 77 |
+
method_name="generate",
|
| 78 |
+
arguments={
|
| 79 |
+
"prompt": music_prompt,
|
| 80 |
+
"duration": music_duration,
|
| 81 |
+
"guidance_scale": self.settings.music_guidance_scale,
|
| 82 |
+
"seed": payload.seed,
|
| 83 |
+
"output_path": targets["music"],
|
| 84 |
+
},
|
| 85 |
+
request_id=request_id,
|
| 86 |
+
duration_seconds=self.settings.zerogpu_musicgen_duration,
|
| 87 |
),
|
| 88 |
)
|
| 89 |
self.tasks.invoke_direct(
|
| 90 |
+
InferenceCommand(
|
| 91 |
+
model_name="wan",
|
| 92 |
+
method_name="generate",
|
| 93 |
+
arguments={
|
| 94 |
+
"image_path": targets["image"],
|
| 95 |
+
"prompt": payload.video_prompt,
|
| 96 |
+
"negative_prompt": "low quality, distorted, static",
|
| 97 |
+
"output_path": targets["video"],
|
| 98 |
+
"steps": self.settings.wan_steps,
|
| 99 |
+
"frames": self.settings.video_frames,
|
| 100 |
+
"fps": self.settings.video_fps,
|
| 101 |
+
"seed": payload.seed,
|
| 102 |
+
"guidance_scale": self.settings.wan_guidance_scale,
|
| 103 |
+
},
|
| 104 |
+
request_id=request_id,
|
| 105 |
+
duration_seconds=self.settings.zerogpu_wan_duration,
|
| 106 |
+
gpu_size="xlarge",
|
| 107 |
),
|
| 108 |
)
|
| 109 |
self.tasks.invoke_direct(
|
| 110 |
+
InferenceCommand(
|
| 111 |
+
model_name="whisper",
|
| 112 |
+
method_name="transcribe",
|
| 113 |
+
arguments={
|
| 114 |
+
"source": targets["voice"],
|
| 115 |
+
"subtitle_path": targets["subtitle"],
|
| 116 |
+
"language": None,
|
| 117 |
+
"task": "transcribe",
|
| 118 |
+
},
|
| 119 |
+
request_id=request_id,
|
| 120 |
+
duration_seconds=self.settings.zerogpu_whisper_duration,
|
| 121 |
),
|
| 122 |
)
|
| 123 |
return targets
|
gradio_ui.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gradio user interface over the shared AI Gateway service layer."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from functools import wraps
|
| 6 |
+
from typing import Any, Awaitable, Callable, TypeVar
|
| 7 |
+
from uuid import uuid4
|
| 8 |
+
|
| 9 |
+
import gradio as gr
|
| 10 |
+
from pydantic import ValidationError
|
| 11 |
+
|
| 12 |
+
from core.errors import GatewayError
|
| 13 |
+
from core.manager import AIService
|
| 14 |
+
from core.schemas import (
|
| 15 |
+
ImageRequest,
|
| 16 |
+
MusicRequest,
|
| 17 |
+
SFXRequest,
|
| 18 |
+
TTSRequest,
|
| 19 |
+
VideoRequest,
|
| 20 |
+
WorkflowRequest,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
F = TypeVar("F", bound=Callable[..., Awaitable[Any]])
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def ui_errors(function: F) -> F:
|
| 28 |
+
"""Convert domain and input errors into safe Gradio errors."""
|
| 29 |
+
|
| 30 |
+
@wraps(function)
|
| 31 |
+
async def wrapped(*args: Any, **kwargs: Any) -> Any:
|
| 32 |
+
try:
|
| 33 |
+
return await function(*args, **kwargs)
|
| 34 |
+
except GatewayError as exc:
|
| 35 |
+
raise gr.Error(f"{exc.code}: {exc.message}") from exc
|
| 36 |
+
except ValidationError as exc:
|
| 37 |
+
raise gr.Error(str(exc)) from exc
|
| 38 |
+
except Exception as exc:
|
| 39 |
+
raise gr.Error("The gateway could not complete this request") from exc
|
| 40 |
+
|
| 41 |
+
return wrapped # type: ignore[return-value]
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class GradioGatewayUI:
|
| 45 |
+
"""Thin UI callbacks that delegate every operation to AIService."""
|
| 46 |
+
|
| 47 |
+
def __init__(self, service: AIService) -> None:
|
| 48 |
+
self.service = service
|
| 49 |
+
|
| 50 |
+
@staticmethod
|
| 51 |
+
def _request_id(request: gr.Request) -> str:
|
| 52 |
+
state = getattr(request, "state", None)
|
| 53 |
+
request_id = getattr(state, "request_id", None)
|
| 54 |
+
if request_id:
|
| 55 |
+
return str(request_id)
|
| 56 |
+
return uuid4().hex
|
| 57 |
+
|
| 58 |
+
def _local_path(self, public_path: str, categories: set[str]) -> str:
|
| 59 |
+
return str(self.service.outputs.resolve_public_input(public_path, categories=categories))
|
| 60 |
+
|
| 61 |
+
@ui_errors
|
| 62 |
+
async def generate_image(
|
| 63 |
+
self,
|
| 64 |
+
prompt: str,
|
| 65 |
+
width: int,
|
| 66 |
+
height: int,
|
| 67 |
+
steps: int,
|
| 68 |
+
seed: int,
|
| 69 |
+
request: gr.Request,
|
| 70 |
+
) -> tuple[str, str]:
|
| 71 |
+
asset = await self.service.generate_image(
|
| 72 |
+
ImageRequest(
|
| 73 |
+
prompt=prompt,
|
| 74 |
+
width=width,
|
| 75 |
+
height=height,
|
| 76 |
+
steps=steps,
|
| 77 |
+
seed=seed,
|
| 78 |
+
),
|
| 79 |
+
self._request_id(request),
|
| 80 |
+
)
|
| 81 |
+
return self._local_path(asset.image, {"images"}), asset.image
|
| 82 |
+
|
| 83 |
+
@ui_errors
|
| 84 |
+
async def generate_video(
|
| 85 |
+
self,
|
| 86 |
+
image_path: str,
|
| 87 |
+
prompt: str,
|
| 88 |
+
request: gr.Request,
|
| 89 |
+
) -> tuple[str, str]:
|
| 90 |
+
asset = await self.service.generate_video(
|
| 91 |
+
VideoRequest(image=image_path, prompt=prompt),
|
| 92 |
+
self._request_id(request),
|
| 93 |
+
)
|
| 94 |
+
return self._local_path(asset.video, {"videos"}), asset.video
|
| 95 |
+
|
| 96 |
+
@ui_errors
|
| 97 |
+
async def generate_speech(
|
| 98 |
+
self,
|
| 99 |
+
text: str,
|
| 100 |
+
voice: str,
|
| 101 |
+
request: gr.Request,
|
| 102 |
+
) -> tuple[str, str]:
|
| 103 |
+
asset = await self.service.generate_speech(
|
| 104 |
+
TTSRequest(text=text, voice=voice.strip() or None), self._request_id(request)
|
| 105 |
+
)
|
| 106 |
+
return self._local_path(asset.voice, {"audio"}), asset.voice
|
| 107 |
+
|
| 108 |
+
@ui_errors
|
| 109 |
+
async def generate_music(
|
| 110 |
+
self,
|
| 111 |
+
prompt: str,
|
| 112 |
+
duration: float,
|
| 113 |
+
request: gr.Request,
|
| 114 |
+
) -> tuple[str, str]:
|
| 115 |
+
asset = await self.service.generate_music(
|
| 116 |
+
MusicRequest(prompt=prompt, duration=duration), self._request_id(request)
|
| 117 |
+
)
|
| 118 |
+
return self._local_path(asset.music, {"music"}), asset.music
|
| 119 |
+
|
| 120 |
+
@ui_errors
|
| 121 |
+
async def generate_sfx(
|
| 122 |
+
self,
|
| 123 |
+
prompt: str,
|
| 124 |
+
duration: float,
|
| 125 |
+
request: gr.Request,
|
| 126 |
+
) -> tuple[str, str]:
|
| 127 |
+
asset = await self.service.generate_sfx(
|
| 128 |
+
SFXRequest(prompt=prompt, duration=duration), self._request_id(request)
|
| 129 |
+
)
|
| 130 |
+
return self._local_path(asset.sfx, {"audio"}), asset.sfx
|
| 131 |
+
|
| 132 |
+
@ui_errors
|
| 133 |
+
async def transcribe_audio(
|
| 134 |
+
self,
|
| 135 |
+
audio_path: str,
|
| 136 |
+
language: str,
|
| 137 |
+
request: gr.Request,
|
| 138 |
+
) -> tuple[str, str]:
|
| 139 |
+
asset = await self.service.transcribe_output(
|
| 140 |
+
audio_path,
|
| 141 |
+
self._request_id(request),
|
| 142 |
+
language=language.strip() or None,
|
| 143 |
+
)
|
| 144 |
+
return asset.text, self._local_path(asset.subtitle, {"subtitles"})
|
| 145 |
+
|
| 146 |
+
@ui_errors
|
| 147 |
+
async def create_episode(
|
| 148 |
+
self,
|
| 149 |
+
title: str,
|
| 150 |
+
script: str,
|
| 151 |
+
image_prompt: str,
|
| 152 |
+
video_prompt: str,
|
| 153 |
+
voice: str,
|
| 154 |
+
request: gr.Request,
|
| 155 |
+
) -> tuple[str, str, str, str, str]:
|
| 156 |
+
assets = await self.service.create_episode(
|
| 157 |
+
WorkflowRequest(
|
| 158 |
+
title=title,
|
| 159 |
+
script=script,
|
| 160 |
+
image_prompt=image_prompt,
|
| 161 |
+
video_prompt=video_prompt,
|
| 162 |
+
voice=voice.strip() or None,
|
| 163 |
+
),
|
| 164 |
+
self._request_id(request),
|
| 165 |
+
)
|
| 166 |
+
return (
|
| 167 |
+
self._local_path(assets.image, {"images"}),
|
| 168 |
+
self._local_path(assets.video, {"videos"}),
|
| 169 |
+
self._local_path(assets.voice, {"audio"}),
|
| 170 |
+
self._local_path(assets.music, {"music"}),
|
| 171 |
+
self._local_path(assets.subtitle, {"subtitles"}),
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
def health(self) -> dict[str, object]:
|
| 175 |
+
return self.service.health()
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def create_gradio_ui(service: AIService) -> gr.Blocks:
|
| 179 |
+
"""Build the queued Space UI without owning any inference logic."""
|
| 180 |
+
try:
|
| 181 |
+
import spaces
|
| 182 |
+
except ImportError:
|
| 183 |
+
pass
|
| 184 |
+
else:
|
| 185 |
+
spaces.disable_gradio_auto_wrap()
|
| 186 |
+
callbacks = GradioGatewayUI(service)
|
| 187 |
+
settings = service.settings
|
| 188 |
+
|
| 189 |
+
with gr.Blocks(title="AI Gateway") as demo:
|
| 190 |
+
gr.Markdown(
|
| 191 |
+
"# AI Gateway\n"
|
| 192 |
+
"One ZeroGPU-aware gateway for images, video, speech, music, SFX, "
|
| 193 |
+
"transcription, REST, and MCP."
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
with gr.Tab("Image"):
|
| 197 |
+
image_prompt = gr.Textbox(label="Prompt", lines=3)
|
| 198 |
+
with gr.Row():
|
| 199 |
+
image_width = gr.Slider(256, 2048, settings.image_width, step=8, label="Width")
|
| 200 |
+
image_height = gr.Slider(
|
| 201 |
+
256, 2048, settings.image_height, step=8, label="Height"
|
| 202 |
+
)
|
| 203 |
+
image_steps = gr.Slider(1, 100, settings.image_steps, step=1, label="Steps")
|
| 204 |
+
image_seed = gr.Number(value=-1, precision=0, label="Seed (-1 = random)")
|
| 205 |
+
image_button = gr.Button("Generate image", variant="primary")
|
| 206 |
+
image_output = gr.Image(type="filepath", label="Image")
|
| 207 |
+
image_public = gr.Textbox(label="Gateway asset path")
|
| 208 |
+
image_button.click(
|
| 209 |
+
callbacks.generate_image,
|
| 210 |
+
[image_prompt, image_width, image_height, image_steps, image_seed],
|
| 211 |
+
[image_output, image_public],
|
| 212 |
+
api_name="generate_image",
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
with gr.Tab("Video"):
|
| 216 |
+
video_image = gr.Textbox(
|
| 217 |
+
label="Image asset path", placeholder="/output/images/....png"
|
| 218 |
+
)
|
| 219 |
+
video_prompt = gr.Textbox(label="Motion prompt", lines=3)
|
| 220 |
+
video_button = gr.Button("Generate video", variant="primary")
|
| 221 |
+
video_output = gr.Video(label="Video")
|
| 222 |
+
video_public = gr.Textbox(label="Gateway asset path")
|
| 223 |
+
video_button.click(
|
| 224 |
+
callbacks.generate_video,
|
| 225 |
+
[video_image, video_prompt],
|
| 226 |
+
[video_output, video_public],
|
| 227 |
+
api_name="generate_video",
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
with gr.Tab("Audio"):
|
| 231 |
+
with gr.Row():
|
| 232 |
+
with gr.Column():
|
| 233 |
+
speech_text = gr.Textbox(label="Narration", lines=5)
|
| 234 |
+
speech_voice = gr.Textbox(
|
| 235 |
+
value=settings.kokoro_default_voice, label="Kokoro voice"
|
| 236 |
+
)
|
| 237 |
+
speech_button = gr.Button("Generate speech")
|
| 238 |
+
speech_output = gr.Audio(type="filepath", label="Speech")
|
| 239 |
+
speech_public = gr.Textbox(label="Speech asset path")
|
| 240 |
+
with gr.Column():
|
| 241 |
+
music_prompt = gr.Textbox(label="Music prompt", lines=3)
|
| 242 |
+
music_duration = gr.Slider(1, 30, settings.music_duration, label="Seconds")
|
| 243 |
+
music_button = gr.Button("Generate music")
|
| 244 |
+
music_output = gr.Audio(type="filepath", label="Music")
|
| 245 |
+
music_public = gr.Textbox(label="Music asset path")
|
| 246 |
+
with gr.Column():
|
| 247 |
+
sfx_prompt = gr.Textbox(label="Sound-effect prompt", lines=3)
|
| 248 |
+
sfx_duration = gr.Slider(0.5, 30, settings.sfx_duration, label="Seconds")
|
| 249 |
+
sfx_button = gr.Button("Generate SFX")
|
| 250 |
+
sfx_output = gr.Audio(type="filepath", label="Sound effect")
|
| 251 |
+
sfx_public = gr.Textbox(label="SFX asset path")
|
| 252 |
+
speech_button.click(
|
| 253 |
+
callbacks.generate_speech,
|
| 254 |
+
[speech_text, speech_voice],
|
| 255 |
+
[speech_output, speech_public],
|
| 256 |
+
api_name="generate_speech",
|
| 257 |
+
)
|
| 258 |
+
music_button.click(
|
| 259 |
+
callbacks.generate_music,
|
| 260 |
+
[music_prompt, music_duration],
|
| 261 |
+
[music_output, music_public],
|
| 262 |
+
api_name="generate_music",
|
| 263 |
+
)
|
| 264 |
+
sfx_button.click(
|
| 265 |
+
callbacks.generate_sfx,
|
| 266 |
+
[sfx_prompt, sfx_duration],
|
| 267 |
+
[sfx_output, sfx_public],
|
| 268 |
+
api_name="generate_sfx",
|
| 269 |
+
)
|
| 270 |
+
|
| 271 |
+
with gr.Tab("Transcription"):
|
| 272 |
+
transcription_source = gr.Textbox(
|
| 273 |
+
label="Audio/video asset path", placeholder="/output/audio/....wav"
|
| 274 |
+
)
|
| 275 |
+
transcription_language = gr.Textbox(label="Language (optional)")
|
| 276 |
+
transcription_button = gr.Button("Transcribe", variant="primary")
|
| 277 |
+
transcription_text = gr.Textbox(label="Transcript", lines=8)
|
| 278 |
+
transcription_subtitle = gr.File(label="SubRip subtitle")
|
| 279 |
+
transcription_button.click(
|
| 280 |
+
callbacks.transcribe_audio,
|
| 281 |
+
[transcription_source, transcription_language],
|
| 282 |
+
[transcription_text, transcription_subtitle],
|
| 283 |
+
api_name="transcribe_audio",
|
| 284 |
+
)
|
| 285 |
+
|
| 286 |
+
with gr.Tab("Episode"):
|
| 287 |
+
episode_title = gr.Textbox(label="Title")
|
| 288 |
+
episode_script = gr.Textbox(label="Script", lines=8)
|
| 289 |
+
episode_image_prompt = gr.Textbox(label="Image prompt", lines=3)
|
| 290 |
+
episode_video_prompt = gr.Textbox(label="Video prompt", lines=3)
|
| 291 |
+
episode_voice = gr.Textbox(
|
| 292 |
+
value=settings.kokoro_default_voice, label="Kokoro voice"
|
| 293 |
+
)
|
| 294 |
+
episode_button = gr.Button("Create episode", variant="primary")
|
| 295 |
+
with gr.Row():
|
| 296 |
+
episode_image = gr.Image(type="filepath", label="Image")
|
| 297 |
+
episode_video = gr.Video(label="Video")
|
| 298 |
+
with gr.Row():
|
| 299 |
+
episode_speech = gr.Audio(type="filepath", label="Narration")
|
| 300 |
+
episode_music = gr.Audio(type="filepath", label="Music")
|
| 301 |
+
episode_subtitle = gr.File(label="Subtitle")
|
| 302 |
+
episode_button.click(
|
| 303 |
+
callbacks.create_episode,
|
| 304 |
+
[
|
| 305 |
+
episode_title,
|
| 306 |
+
episode_script,
|
| 307 |
+
episode_image_prompt,
|
| 308 |
+
episode_video_prompt,
|
| 309 |
+
episode_voice,
|
| 310 |
+
],
|
| 311 |
+
[
|
| 312 |
+
episode_image,
|
| 313 |
+
episode_video,
|
| 314 |
+
episode_speech,
|
| 315 |
+
episode_music,
|
| 316 |
+
episode_subtitle,
|
| 317 |
+
],
|
| 318 |
+
api_name="create_episode",
|
| 319 |
+
)
|
| 320 |
+
|
| 321 |
+
with gr.Tab("System"):
|
| 322 |
+
health_output = gr.JSON(value=callbacks.health(), label="Gateway health")
|
| 323 |
+
gr.Button("Refresh health").click(
|
| 324 |
+
callbacks.health, outputs=health_output, api_name="health_check"
|
| 325 |
+
)
|
| 326 |
+
gr.Markdown("REST documentation: [`/docs`](/docs) · MCP endpoint: `/mcp`")
|
| 327 |
+
|
| 328 |
+
return demo.queue(
|
| 329 |
+
max_size=settings.max_queue,
|
| 330 |
+
default_concurrency_limit=settings.max_queue,
|
| 331 |
+
)
|
packages.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
espeak-ng
|
| 2 |
+
ffmpeg
|
| 3 |
+
git
|
| 4 |
+
libsndfile1
|
requirements-test.txt
CHANGED
|
@@ -1,5 +1,7 @@
|
|
| 1 |
fastapi>=0.115,<1.0
|
| 2 |
fastmcp>=3.4.4,<4.0
|
|
|
|
|
|
|
| 3 |
pydantic>=2.9,<3.0
|
| 4 |
pydantic-settings>=2.5,<3.0
|
| 5 |
python-multipart>=0.0.12,<1.0
|
|
|
|
| 1 |
fastapi>=0.115,<1.0
|
| 2 |
fastmcp>=3.4.4,<4.0
|
| 3 |
+
gradio>=6.20,<7.0
|
| 4 |
+
spaces>=0.51,<1.0
|
| 5 |
pydantic>=2.9,<3.0
|
| 6 |
pydantic-settings>=2.5,<3.0
|
| 7 |
python-multipart>=0.0.12,<1.0
|
requirements.txt
CHANGED
|
@@ -1,6 +1,8 @@
|
|
| 1 |
# Web runtime
|
| 2 |
fastapi>=0.115,<1.0
|
| 3 |
fastmcp>=3.4.4,<4.0
|
|
|
|
|
|
|
| 4 |
uvicorn[standard]>=0.32,<1.0
|
| 5 |
pydantic>=2.9,<3.0
|
| 6 |
pydantic-settings>=2.5,<3.0
|
|
|
|
| 1 |
# Web runtime
|
| 2 |
fastapi>=0.115,<1.0
|
| 3 |
fastmcp>=3.4.4,<4.0
|
| 4 |
+
gradio>=6.20,<7.0
|
| 5 |
+
spaces>=0.51,<1.0
|
| 6 |
uvicorn[standard]>=0.32,<1.0
|
| 7 |
pydantic>=2.9,<3.0
|
| 8 |
pydantic-settings>=2.5,<3.0
|
routes/health.py
CHANGED
|
@@ -15,10 +15,11 @@ router = APIRouter(tags=["system"])
|
|
| 15 |
async def health(services=Depends(get_services)) -> HealthResponse:
|
| 16 |
"""Report service state without loading any model."""
|
| 17 |
snapshot = services.ai.health()
|
|
|
|
| 18 |
return HealthResponse(
|
| 19 |
status="ok",
|
| 20 |
version=services.settings.app_version,
|
| 21 |
-
device=
|
| 22 |
queue_depth=snapshot["queue"],
|
| 23 |
queue_capacity=services.tasks.queue.capacity,
|
| 24 |
active_model=services.loader.active_model,
|
|
|
|
| 15 |
async def health(services=Depends(get_services)) -> HealthResponse:
|
| 16 |
"""Report service state without loading any model."""
|
| 17 |
snapshot = services.ai.health()
|
| 18 |
+
server = services.ai.server_info()
|
| 19 |
return HealthResponse(
|
| 20 |
status="ok",
|
| 21 |
version=services.settings.app_version,
|
| 22 |
+
device=server["device"],
|
| 23 |
queue_depth=snapshot["queue"],
|
| 24 |
queue_capacity=services.tasks.queue.capacity,
|
| 25 |
active_model=services.loader.active_model,
|
tests/test_api.py
CHANGED
|
@@ -30,6 +30,15 @@ def test_health_does_not_load_a_model(tmp_path: Path) -> None:
|
|
| 30 |
assert response.json()["active_model"] is None
|
| 31 |
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
def test_validation_errors_have_stable_shape(tmp_path: Path) -> None:
|
| 34 |
application = make_app(tmp_path)
|
| 35 |
with TestClient(application) as client:
|
|
|
|
| 30 |
assert response.json()["active_model"] is None
|
| 31 |
|
| 32 |
|
| 33 |
+
def test_root_redirects_to_gradio_ui(tmp_path: Path) -> None:
|
| 34 |
+
application = make_app(tmp_path)
|
| 35 |
+
with TestClient(application) as client:
|
| 36 |
+
response = client.get("/", follow_redirects=False)
|
| 37 |
+
|
| 38 |
+
assert response.status_code == 307
|
| 39 |
+
assert response.headers["location"] == "/ui"
|
| 40 |
+
|
| 41 |
+
|
| 42 |
def test_validation_errors_have_stable_shape(tmp_path: Path) -> None:
|
| 43 |
application = make_app(tmp_path)
|
| 44 |
with TestClient(application) as client:
|
tests/test_executor.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ZeroGPU command serialization and parent-side error restoration."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import pickle
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import pytest
|
| 9 |
+
|
| 10 |
+
from core.errors import GatewayError
|
| 11 |
+
from core.executor import (
|
| 12 |
+
CommandError,
|
| 13 |
+
CommandResult,
|
| 14 |
+
InferenceCommand,
|
| 15 |
+
execute_inference,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def test_inference_command_is_pickle_safe(tmp_path: Path) -> None:
|
| 20 |
+
command = InferenceCommand(
|
| 21 |
+
model_name="flux",
|
| 22 |
+
method_name="generate",
|
| 23 |
+
arguments={"prompt": "city", "output_path": tmp_path / "image.png"},
|
| 24 |
+
request_id="request-1",
|
| 25 |
+
duration_seconds=180,
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
restored = pickle.loads(pickle.dumps(command))
|
| 29 |
+
|
| 30 |
+
assert restored == command
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_serialized_worker_error_restores_gateway_error(monkeypatch) -> None:
|
| 34 |
+
command = InferenceCommand(
|
| 35 |
+
model_name="flux",
|
| 36 |
+
method_name="generate",
|
| 37 |
+
arguments={},
|
| 38 |
+
request_id="request-1",
|
| 39 |
+
duration_seconds=180,
|
| 40 |
+
)
|
| 41 |
+
monkeypatch.setattr(
|
| 42 |
+
"core.executor._invoke_large",
|
| 43 |
+
lambda _: CommandResult(
|
| 44 |
+
error=CommandError("flux ran out of memory", 507, "out_of_memory")
|
| 45 |
+
),
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
with pytest.raises(GatewayError) as captured:
|
| 49 |
+
execute_inference(command)
|
| 50 |
+
|
| 51 |
+
assert captured.value.status_code == 507
|
| 52 |
+
assert captured.value.code == "out_of_memory"
|
tests/test_queue.py
CHANGED
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
| 5 |
import asyncio
|
| 6 |
import threading
|
| 7 |
import time
|
|
|
|
| 8 |
|
| 9 |
import pytest
|
| 10 |
|
|
@@ -63,3 +64,16 @@ async def test_queue_timeout_cancels_job_before_inference() -> None:
|
|
| 63 |
assert queued_job_ran is False
|
| 64 |
assert queue.active is False
|
| 65 |
await queue.stop()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
import asyncio
|
| 6 |
import threading
|
| 7 |
import time
|
| 8 |
+
from contextvars import ContextVar
|
| 9 |
|
| 10 |
import pytest
|
| 11 |
|
|
|
|
| 64 |
assert queued_job_ran is False
|
| 65 |
assert queue.active is False
|
| 66 |
await queue.stop()
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
async def test_queue_preserves_request_context_for_worker() -> None:
|
| 70 |
+
queue = InferenceQueue(capacity=1, timeout_seconds=5)
|
| 71 |
+
request_context: ContextVar[str] = ContextVar("test_request", default="missing")
|
| 72 |
+
token = request_context.set("request-identity")
|
| 73 |
+
try:
|
| 74 |
+
result = await queue.submit("request", "context", request_context.get)
|
| 75 |
+
finally:
|
| 76 |
+
request_context.reset(token)
|
| 77 |
+
await queue.stop()
|
| 78 |
+
|
| 79 |
+
assert result == "request-identity"
|
tests/test_workflow.py
CHANGED
|
@@ -6,6 +6,7 @@ from pathlib import Path
|
|
| 6 |
from typing import Any, Callable
|
| 7 |
|
| 8 |
from config import Settings
|
|
|
|
| 9 |
from core.workflow import WorkflowService
|
| 10 |
from routes.schemas import WorkflowRequest
|
| 11 |
from utils.files import OutputManager
|
|
@@ -42,11 +43,11 @@ class FakeTasks:
|
|
| 42 |
assert label == "workflow"
|
| 43 |
return action()
|
| 44 |
|
| 45 |
-
def invoke_direct(
|
| 46 |
-
self
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
return
|
| 50 |
|
| 51 |
|
| 52 |
async def test_workflow_runs_in_required_order(tmp_path: Path) -> None:
|
|
|
|
| 6 |
from typing import Any, Callable
|
| 7 |
|
| 8 |
from config import Settings
|
| 9 |
+
from core.executor import InferenceCommand
|
| 10 |
from core.workflow import WorkflowService
|
| 11 |
from routes.schemas import WorkflowRequest
|
| 12 |
from utils.files import OutputManager
|
|
|
|
| 43 |
assert label == "workflow"
|
| 44 |
return action()
|
| 45 |
|
| 46 |
+
def invoke_direct(self, command: InferenceCommand) -> Any:
|
| 47 |
+
self.calls.append(command.model_name)
|
| 48 |
+
adapter = FakeAdapter(command.model_name)
|
| 49 |
+
method = getattr(adapter, command.method_name)
|
| 50 |
+
return method(**command.arguments)
|
| 51 |
|
| 52 |
|
| 53 |
async def test_workflow_runs_in_required_order(tmp_path: Path) -> None:
|