"""High-level inference execution and observability.""" from __future__ import annotations from pathlib import Path from typing import Any, Callable, Literal, Protocol, TypeVar from config import Settings from core.errors import GatewayError from core.executor import InferenceCommand, execute_inference from core.loader import ModelLoader from core.queue import InferenceQueue from core.runtime import gpu_available, memory_stats, zerogpu_enabled from core.schemas import ( EpisodeAssets, ImageAsset, ImageRequest, MusicAsset, MusicRequest, SFXAsset, SFXRequest, SpeechAsset, TTSRequest, TranscriptionAsset, VideoAsset, VideoRequest, WorkflowRequest, ) from utils.files import OutputManager from utils.validation import validate_image_dimensions T = TypeVar("T") class WorkflowRunner(Protocol): """Structural contract that avoids coupling the manager to workflow internals.""" async def run( self, payload: WorkflowRequest, request_id: str ) -> dict[str, Path]: ... class TaskManager: """Submit inference to one queue and manage model lifecycle consistently.""" def __init__(self, settings: Settings, loader: ModelLoader) -> None: self.settings = settings self.loader = loader self.queue = InferenceQueue(settings.max_queue, settings.job_timeout_seconds) async def start(self) -> None: await self.queue.start() async def stop(self) -> None: await self.queue.stop() async def run( self, command: InferenceCommand, ) -> Any: """Queue one model action.""" return await self.queue.submit( command.request_id, command.model_name, lambda: self.invoke_direct(command), ) async def run_exclusive( self, request_id: str, label: str, action: Callable[[], T] ) -> T: """Queue a compound operation as one non-interleavable job.""" return await self.queue.submit(request_id, label, action) @staticmethod def invoke_direct(command: InferenceCommand) -> Any: """Invoke a serializable command; workflows reuse this method.""" return execute_inference(command) class AIService: """Shared AI capability layer used by every external interface.""" model_names = ("flux", "wan22", "kokoro", "musicgen", "whisper", "sfx") rest_endpoints = ( "/api/image", "/api/video", "/tts", "/music", "/sfx", "/transcribe", "/workflow", "/health", ) def __init__( self, settings: Settings, tasks: TaskManager, loader: ModelLoader, outputs: OutputManager, workflows: WorkflowRunner, ) -> None: self.settings = settings self.tasks = tasks self.loader = loader self.outputs = outputs self.workflows = workflows async def generate_image(self, payload: ImageRequest, request_id: str) -> ImageAsset: """Validate, queue, and persist one FLUX image.""" width = payload.width if payload.width is not None else self.settings.image_width height = payload.height if payload.height is not None else self.settings.image_height steps = payload.steps if payload.steps is not None else self.settings.image_steps guidance_scale = ( payload.guidance_scale if payload.guidance_scale is not None else self.settings.flux_guidance_scale ) validate_image_dimensions(width, height) target = self.outputs.allocate("images") try: await self.tasks.run( InferenceCommand( model_name="flux", method_name="generate", arguments={ "prompt": payload.prompt, "output_path": target, "width": width, "height": height, "steps": steps, "seed": payload.seed, "guidance_scale": guidance_scale, }, request_id=request_id, duration_seconds=self.settings.zerogpu_flux_duration, ), ) except Exception: self.outputs.remove(target) raise return ImageAsset(image=self.outputs.public_path(target)) async def generate_video(self, payload: VideoRequest, request_id: str) -> VideoAsset: """Validate, queue, and persist a WAN image-to-video result.""" frames = payload.frames if payload.frames is not None else self.settings.video_frames fps = payload.fps if payload.fps is not None else self.settings.video_fps steps = payload.steps if payload.steps is not None else self.settings.wan_steps guidance_scale = ( payload.guidance_scale if payload.guidance_scale is not None else self.settings.wan_guidance_scale ) if (frames - 1) % 4: raise GatewayError("frames must equal 4k + 1", code="invalid_frame_count") source = self.outputs.resolve_public_input(payload.image, categories={"images"}) target = self.outputs.allocate("videos") try: await self.tasks.run( InferenceCommand( model_name="wan", method_name="generate", arguments={ "image_path": source, "prompt": payload.prompt, "negative_prompt": payload.negative_prompt, "output_path": target, "steps": steps, "frames": frames, "fps": fps, "seed": payload.seed, "guidance_scale": guidance_scale, }, request_id=request_id, duration_seconds=self.settings.zerogpu_wan_duration, gpu_size="xlarge", ), ) except Exception: self.outputs.remove(target) raise return VideoAsset(video=self.outputs.public_path(target)) async def generate_speech(self, payload: TTSRequest, request_id: str) -> SpeechAsset: """Validate, queue, and persist Kokoro speech.""" voice = payload.voice or self.settings.kokoro_default_voice target = self.outputs.allocate("audio") try: await self.tasks.run( InferenceCommand( model_name="kokoro", method_name="synthesize", arguments={ "text": payload.text, "voice": voice, "speed": payload.speed, "output_path": target, }, request_id=request_id, duration_seconds=self.settings.zerogpu_kokoro_duration, ), ) except Exception: self.outputs.remove(target) raise return SpeechAsset(voice=self.outputs.public_path(target)) async def generate_music(self, payload: MusicRequest, request_id: str) -> MusicAsset: """Validate, queue, and persist MusicGen output.""" duration = ( payload.duration if payload.duration is not None else self.settings.music_duration ) guidance_scale = ( payload.guidance_scale if payload.guidance_scale is not None else self.settings.music_guidance_scale ) target = self.outputs.allocate("music") try: await self.tasks.run( InferenceCommand( model_name="musicgen", method_name="generate", arguments={ "prompt": payload.prompt, "duration": duration, "guidance_scale": guidance_scale, "seed": payload.seed, "output_path": target, }, request_id=request_id, duration_seconds=self.settings.zerogpu_musicgen_duration, ), ) except Exception: self.outputs.remove(target) raise return MusicAsset(music=self.outputs.public_path(target)) async def generate_sfx(self, payload: SFXRequest, request_id: str) -> SFXAsset: """Validate, queue, and persist an AudioLDM2 sound effect.""" duration = ( payload.duration if payload.duration is not None else self.settings.sfx_duration ) steps = payload.steps if payload.steps is not None else self.settings.sfx_steps target = self.outputs.allocate("audio") try: await self.tasks.run( InferenceCommand( model_name="sfx", method_name="generate", arguments={ "prompt": payload.prompt, "duration": duration, "steps": steps, "seed": payload.seed, "output_path": target, }, request_id=request_id, duration_seconds=self.settings.zerogpu_sfx_duration, ), ) except Exception: self.outputs.remove(target) raise return SFXAsset(sfx=self.outputs.public_path(target)) async def transcribe_source( self, source: Path, request_id: str, *, language: str | None = None, task: Literal["transcribe", "translate"] = "transcribe", ) -> TranscriptionAsset: """Queue faster-whisper for a trusted local source file.""" subtitle = self.outputs.allocate("subtitles") try: result = await self.tasks.run( InferenceCommand( model_name="whisper", method_name="transcribe", arguments={ "source": source, "subtitle_path": subtitle, "language": language, "task": task, }, request_id=request_id, duration_seconds=self.settings.zerogpu_whisper_duration, ), ) except Exception: self.outputs.remove(subtitle) raise return TranscriptionAsset( text=result["text"], subtitle=self.outputs.public_path(subtitle), language=result["language"], ) async def transcribe_output( self, audio: str, request_id: str, *, language: str | None = None, task: Literal["transcribe", "translate"] = "transcribe", ) -> TranscriptionAsset: """Resolve and transcribe a gateway-generated audio or video asset.""" source = self.outputs.resolve_public_input( audio, categories={"audio", "music", "videos"} ) return await self.transcribe_source( source, request_id, language=language, task=task ) async def create_episode( self, payload: WorkflowRequest, request_id: str ) -> EpisodeAssets: """Run the full workflow as one exclusive queue job.""" width = payload.width if payload.width is not None else self.settings.image_width height = payload.height if payload.height is not None else self.settings.image_height validate_image_dimensions(width, height) assets = await self.workflows.run(payload, request_id) return EpisodeAssets( image=self.outputs.public_path(assets["image"]), video=self.outputs.public_path(assets["video"]), voice=self.outputs.public_path(assets["voice"]), music=self.outputs.public_path(assets["music"]), subtitle=self.outputs.public_path(assets["subtitle"]), ) def health(self) -> dict[str, object]: """Return a no-load health snapshot for REST and MCP.""" return { "status": "healthy", "gpu": "available" if gpu_available() else "unavailable", "queue": self.tasks.queue.depth, "models_loaded": self._loaded_models(), } def list_models(self) -> dict[str, list[str]]: """Return every discoverable gateway model capability.""" return {"models": list(self.model_names)} def server_info(self) -> dict[str, object]: """Return operational metadata without loading a model.""" return { "version": self.settings.app_version, "device": "zerogpu" if zerogpu_enabled() else self.loader.device, "gpu": "available" if gpu_available() else "unavailable", "memory": memory_stats(), "available_endpoints": [*self.rest_endpoints, "/mcp"], "loaded_models": self._loaded_models(), "queue": { "depth": self.tasks.queue.depth, "capacity": self.tasks.queue.capacity, "active": self.tasks.queue.active, }, } def _loaded_models(self) -> list[str]: """Expose stable public model names rather than adapter implementation names.""" return [ "wan22" if model_name == "wan" else model_name for model_name in self.loader.loaded_models ]