basyx commited on
Commit
eb808a5
·
verified ·
1 Parent(s): f814e4f

Upload 60 files

Browse files
.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: python -m pyflakes app.py config.py core gateway_mcp models routes utils tests
 
 
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: docker
7
- app_port: 7860
 
 
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 Docker
15
- Space and exposes REST endpoints suitable for n8n, Vercel server functions, and
16
- mobile backends. The same capabilities are also exposed as Model Context Protocol
17
- (MCP) tools for ChatGPT, Claude, Cursor, Codex, and other MCP clients.
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; it is moved off GPU after inference and fully unloaded when a
22
- different model is requested.
23
 
24
  ## Models
25
 
@@ -40,9 +42,9 @@ checkpoint.
40
  ## Architecture
41
 
42
  ```text
43
- REST clients MCP 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 routes and MCP handlers contain no inference implementation. Both call the
63
- same `core/manager.py` service instance, queue, loader, and workflow. Model adapters
64
- contain no API code. The full workflow is one queue item, so another REST or MCP
65
- request cannot take the GPU between its stages. The MCP integration lives under
66
- `gateway_mcp/`; using a local top-level `mcp/` package would shadow the official MCP
67
- Python SDK required by FastMCP.
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
- uvicorn app:app --host 0.0.0.0 --port 7860 --workers 1
80
  ```
81
 
82
  Do not increase the worker count: serialization is process-local, so multiple
83
  workers would allow concurrent model loading.
84
 
85
- Docker:
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
- Its Docker health check calls `GET http://127.0.0.1:7860/health` without loading
102
- a model.
103
 
104
  ## Hugging Face Space deployment
105
 
106
- 1. Create a new Space with **Docker** as the SDK.
 
107
  2. Push this directory to the Space repository.
108
- 3. Add `HF_TOKEN` as a Space secret if a configured checkpoint is gated.
109
- 4. Attach persistent storage if model weights should survive rebuilds. The image
110
- uses `/data/.huggingface` for the Hub cache and `/data/output` for assets.
111
- 5. Select GPU hardware with enough system RAM and VRAM for WAN 2.2. The default
112
- A14B checkpoint is substantially larger than every other configured model.
113
-
114
- ### ZeroGPU limitation
115
 
116
- As of July 2026, Hugging Face documents ZeroGPU as exclusively compatible with
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
- The model isolation, single-worker queue, CPU offload, and cleanup logic are
122
- suitable for a later Gradio-SDK port, but this Docker application deliberately
123
- does not expose a nonfunctional ZeroGPU switch. A ZeroGPU edition must move the
124
- inference entry points behind top-level `spaces.GPU`-decorated Gradio handlers;
125
- the scheduler cannot serialize this API server's queued Python closures.
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 (`/data/output` in Docker) |
146
- | `TMP_FOLDER` | `tmp` | Request-scoped uploads (`/tmp/ai-gateway` in Docker) |
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
- - `out_of_memory`: reduce image/video settings, keep CPU offload enabled, or select
365
- larger GPU hardware. The loader releases GPU residency and clears Torch caches
366
- between models, but WAN 2.2 still has a high minimum memory requirement.
 
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
- if request.url.path not in public_paths:
 
 
 
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="/docs")
190
 
191
  application.mount(
192
  "/output",
193
  StaticFiles(directory=resolved_settings.output_folder, check_dir=False),
194
  name="output",
195
  )
196
- return application
 
 
 
 
 
 
 
 
 
197
 
198
 
199
  app = create_app()
@@ -203,4 +223,4 @@ if __name__ == "__main__":
203
  import uvicorn
204
 
205
  settings = get_settings()
206
- uvicorn.run("app:app", host=settings.host, port=settings.port, proxy_headers=True)
 
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 TYPE_CHECKING, Callable, Literal, Protocol, TypeVar, cast
9
 
10
  from config import Settings
11
- from core.errors import GatewayError, InferenceError, OutOfMemoryError
 
12
  from core.loader import ModelLoader
13
  from core.queue import InferenceQueue
14
- from core.runtime import gpu_available, inference_context, memory_stats
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
- model_name: str,
73
- request_id: str,
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(model_name, request_id, action),
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
- def invoke_direct(
90
- self,
91
- model_name: str,
92
- request_id: str,
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
- "flux",
174
- request_id,
175
- lambda adapter: cast("FluxModel", adapter).generate(
176
- prompt=payload.prompt,
177
- output_path=target,
178
- width=width,
179
- height=height,
180
- steps=steps,
181
- seed=payload.seed,
182
- guidance_scale=guidance_scale,
 
 
 
 
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
- "wan",
207
- request_id,
208
- lambda adapter: cast("WanModel", adapter).generate(
209
- image_path=source,
210
- prompt=payload.prompt,
211
- negative_prompt=payload.negative_prompt,
212
- output_path=target,
213
- steps=steps,
214
- frames=frames,
215
- fps=fps,
216
- seed=payload.seed,
217
- guidance_scale=guidance_scale,
 
 
 
 
 
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
- "kokoro",
232
- request_id,
233
- lambda adapter: cast("KokoroModel", adapter).synthesize(
234
- text=payload.text,
235
- voice=voice,
236
- speed=payload.speed,
237
- output_path=target,
 
 
 
 
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
- "musicgen",
259
- request_id,
260
- lambda adapter: cast("MusicGenModel", adapter).generate(
261
- prompt=payload.prompt,
262
- duration=duration,
263
- guidance_scale=guidance_scale,
264
- seed=payload.seed,
265
- output_path=target,
 
 
 
 
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
- "sfx",
283
- request_id,
284
- lambda adapter: cast("SFXModel", adapter).generate(
285
- prompt=payload.prompt,
286
- duration=duration,
287
- steps=steps,
288
- seed=payload.seed,
289
- output_path=target,
 
 
 
 
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
- "whisper",
310
- request_id,
311
- lambda adapter: cast("WhisperModel", adapter).transcribe(
312
- source=source,
313
- subtitle_path=subtitle,
314
- language=language,
315
- task=task,
 
 
 
 
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 CUDA availability without making health checks fail."""
 
 
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
- "flux",
47
- request_id,
48
- lambda adapter: cast("FluxModel", adapter).generate(
49
- prompt=payload.image_prompt,
50
- output_path=targets["image"],
51
- width=width,
52
- height=height,
53
- steps=self.settings.image_steps,
54
- seed=payload.seed,
55
- guidance_scale=self.settings.flux_guidance_scale,
 
 
 
 
56
  ),
57
  )
58
  self.tasks.invoke_direct(
59
- "kokoro",
60
- request_id,
61
- lambda adapter: cast("KokoroModel", adapter).synthesize(
62
- text=payload.script,
63
- voice=voice,
64
- speed=1.0,
65
- output_path=targets["voice"],
 
 
 
 
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
- "musicgen",
75
- request_id,
76
- lambda adapter: cast("MusicGenModel", adapter).generate(
77
- prompt=music_prompt,
78
- duration=music_duration,
79
- guidance_scale=self.settings.music_guidance_scale,
80
- seed=payload.seed,
81
- output_path=targets["music"],
 
 
 
 
82
  ),
83
  )
84
  self.tasks.invoke_direct(
85
- "wan",
86
- request_id,
87
- lambda adapter: cast("WanModel", adapter).generate(
88
- image_path=targets["image"],
89
- prompt=payload.video_prompt,
90
- negative_prompt="low quality, distorted, static",
91
- output_path=targets["video"],
92
- steps=self.settings.wan_steps,
93
- frames=self.settings.video_frames,
94
- fps=self.settings.video_fps,
95
- seed=payload.seed,
96
- guidance_scale=self.settings.wan_guidance_scale,
 
 
 
 
 
97
  ),
98
  )
99
  self.tasks.invoke_direct(
100
- "whisper",
101
- request_id,
102
- lambda adapter: cast("WhisperModel", adapter).transcribe(
103
- source=targets["voice"],
104
- subtitle_path=targets["subtitle"],
105
- language=None,
106
- task="transcribe",
 
 
 
 
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=services.loader.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, name: str, request_id: str, action: Callable[[FakeAdapter], Any]
47
- ) -> Any:
48
- self.calls.append(name)
49
- return action(FakeAdapter(name))
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: