Download app.py from FlameF0X/bot: direct link, hf CLI and curl.
- Browser
- Download file 10.8 kB
-
https://huggingface.co/spaces/FlameF0X/bot/resolve/main/app.py
- Command line
-
hf download hf://spaces/FlameF0X/bot/app.py
-
curl -L -o app.py https://huggingface.co/spaces/FlameF0X/bot/resolve/main/app.py
10.8 kB
| import os | |
| import base64 | |
| import logging | |
| import subprocess | |
| import tempfile | |
| import threading | |
| import time | |
| import uuid | |
| from pathlib import Path | |
| import gradio as gr | |
| import torch | |
| import uvicorn | |
| from fastapi import Depends, FastAPI, Header, HTTPException | |
| from PIL import Image | |
| from pydantic import BaseModel | |
| from transformers import AutoModelForImageTextToText, AutoProcessor | |
| logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") | |
| log = logging.getLogger("omni-api") | |
| MODEL_ID = os.getenv("OMNI_MODEL_ID", "LiquidAI/LFM3-VL-2B") | |
| SERVED_NAME = os.getenv("OMNI_MODEL_NAME", "lfm3-omni-2b") | |
| API_KEY = os.environ.get("OMNI_API_KEY", "") | |
| if not API_KEY: | |
| raise RuntimeError("OMNI_API_KEY is not set. Add it to the Space secrets.") | |
| ENABLE_VIDEO = True | |
| VIDEO_MAX_FRAMES = 64 | |
| DEFAULT_MAX_TOKENS = 512 | |
| HARD_MAX_TOKENS = 1024 | |
| MAX_BYTES = 25 * 1024 * 1024 | |
| # ---------- model ---------- | |
| if torch.cuda.is_available(): | |
| DEVICE, DTYPE = "cuda", torch.bfloat16 | |
| else: | |
| DEVICE, DTYPE = "cpu", torch.float32 | |
| log.info("Loading %s on %s (%s)...", MODEL_ID, DEVICE, DTYPE) | |
| processor = AutoProcessor.from_pretrained(MODEL_ID, trust_remote_code=True) | |
| model = AutoModelForImageTextToText.from_pretrained( | |
| MODEL_ID, dtype=DTYPE, device_map=DEVICE, trust_remote_code=True | |
| ).eval() | |
| log.info("Model loaded.") | |
| # The model handles one generation at a time. | |
| model_lock = threading.Lock() | |
| # ---------- helpers ---------- | |
| def to_wav(src: str, dst: str) -> bool: | |
| r = subprocess.run( | |
| ["ffmpeg", "-y", "-loglevel", "error", "-i", src, | |
| "-vn", "-ac", "1", "-ar", "16000", dst], | |
| capture_output=True, | |
| ) | |
| return r.returncode == 0 and Path(dst).exists() | |
| def video_has_audio(path: str) -> bool: | |
| r = subprocess.run( | |
| ["ffprobe", "-v", "error", "-select_streams", "a", | |
| "-show_entries", "stream=codec_type", "-of", "csv=p=0", path], | |
| capture_output=True, text=True, | |
| ) | |
| return bool(r.stdout.strip()) | |
| def video_frame_count(path: str) -> int: | |
| r = subprocess.run( | |
| ["ffprobe", "-v", "error", "-select_streams", "v:0", "-count_frames", | |
| "-show_entries", "stream=nb_read_frames", "-of", "csv=p=0", path], | |
| capture_output=True, text=True, | |
| ) | |
| try: | |
| return int(r.stdout.strip()) | |
| except ValueError: | |
| return 0 | |
| def collect_video_paths(messages: list[dict]) -> list[str]: | |
| paths = [] | |
| for m in messages: | |
| content = m.get("content") | |
| if isinstance(content, list): | |
| for part in content: | |
| if part.get("type") == "video": | |
| paths.append(part["video"]) | |
| return paths | |
| def decode_b64(data: str) -> bytes: | |
| try: | |
| raw = base64.b64decode(data, validate=True) | |
| except Exception: | |
| raise HTTPException(400, "Invalid base64 data") | |
| if len(raw) > MAX_BYTES: | |
| raise HTTPException(413, "File is larger than 25 MB") | |
| return raw | |
| def decode_data_url(url: str) -> tuple[str, bytes]: | |
| if not url.startswith("data:") or ";base64," not in url: | |
| raise HTTPException(400, "Only base64 data URLs are supported") | |
| header, payload = url.split(",", 1) | |
| mime = header[5:].split(";")[0] | |
| return mime, decode_b64(payload) | |
| def safe_ext(name: str, default: str) -> str: | |
| cleaned = "".join(c for c in name.lower() if c.isalnum())[:8] | |
| return cleaned or default | |
| def convert_content(content, workdir: str, msg_idx: int): | |
| """Turn an OpenAI-style content value into the format the processor expects.""" | |
| if isinstance(content, str): | |
| return content, False | |
| if not isinstance(content, list): | |
| raise HTTPException(400, "content must be a string or a list of parts") | |
| out, has_video = [], False | |
| for i, part in enumerate(content): | |
| ptype = part.get("type") | |
| tag = f"m{msg_idx}_p{i}" | |
| if ptype == "text": | |
| out.append({"type": "text", "text": part.get("text", "")}) | |
| elif ptype == "image_url": | |
| _, data = decode_data_url(part["image_url"]["url"]) | |
| src = os.path.join(workdir, f"{tag}.bin") | |
| dst = os.path.join(workdir, f"{tag}.png") | |
| Path(src).write_bytes(data) | |
| try: | |
| Image.open(src).convert("RGB").save(dst, "PNG") | |
| except Exception: | |
| raise HTTPException(400, "Could not decode image") | |
| out.append({"type": "image", "image": dst}) | |
| elif ptype == "input_audio": | |
| audio = part["input_audio"] | |
| data = decode_b64(audio["data"]) | |
| src = os.path.join(workdir, f"{tag}.{safe_ext(audio.get('format', 'wav'), 'wav')}") | |
| dst = os.path.join(workdir, f"{tag}.wav") | |
| Path(src).write_bytes(data) | |
| if not to_wav(src, dst): | |
| raise HTTPException(400, "Could not decode audio") | |
| out.append({"type": "audio", "audio": dst}) | |
| elif ptype == "video_url": | |
| if not ENABLE_VIDEO: | |
| raise HTTPException(400, "Video is disabled on this server") | |
| mime, data = decode_data_url(part["video_url"]["url"]) | |
| ext = "mp4" | |
| if "quicktime" in mime: | |
| ext = "mov" | |
| elif "webm" in mime: | |
| ext = "webm" | |
| elif "matroska" in mime: | |
| ext = "mkv" | |
| dst = os.path.join(workdir, f"{tag}.{ext}") | |
| Path(dst).write_bytes(data) | |
| # Clips with fewer than 2 frames (e.g. a static GIF) can't be processed as video. | |
| if video_frame_count(dst) < 2: | |
| frame = os.path.join(workdir, f"{tag}_frame.png") | |
| subprocess.run( | |
| ["ffmpeg", "-y", "-loglevel", "error", "-i", dst, | |
| "-frames:v", "1", frame], | |
| capture_output=True, | |
| ) | |
| if not Path(frame).exists(): | |
| raise HTTPException(400, "Could not read video") | |
| out.append({"type": "image", "image": frame}) | |
| continue | |
| out.append({"type": "video", "video": dst}) | |
| has_video = True | |
| else: | |
| raise HTTPException(400, f"Unsupported content type: {ptype}") | |
| return out, has_video | |
| def convert_messages(messages: list[dict], workdir: str): | |
| converted, has_video = [], False | |
| for idx, m in enumerate(messages): | |
| role = m.get("role") | |
| if role not in ("system", "user", "assistant"): | |
| raise HTTPException(400, f"Unsupported role: {role}") | |
| content, v = convert_content(m.get("content", ""), workdir, idx) | |
| has_video = has_video or v | |
| converted.append({"role": role, "content": content}) | |
| return converted, has_video | |
| def run_model(messages: list[dict], has_video: bool, max_new_tokens: int) -> str: | |
| videos = collect_video_paths(messages) if has_video else [] | |
| # Only ask for the soundtrack when every video actually has one. | |
| use_audio = bool(videos) and all(video_has_audio(v) for v in videos) | |
| audio_kwargs = ( | |
| {"load_audio_from_video": True, "processor_kwargs": {"use_audio_in_video": True}} | |
| if use_audio else {} | |
| ) | |
| # Close the reasoning block so the model answers directly. | |
| # The template's generation prompt opens <think>; this prefilled assistant turn | |
| # supplies an empty think block and the model continues from there. | |
| prefilled = messages + [ | |
| {"role": "assistant", "content": "<think>\n\n</think>\n\n"} | |
| ] | |
| def build_inputs(kwargs: dict): | |
| return processor.apply_chat_template( | |
| prefilled, | |
| add_generation_prompt=False, | |
| continue_final_message=True, | |
| tokenize=True, | |
| return_dict=True, | |
| return_tensors="pt", | |
| **kwargs, | |
| ).to(DEVICE) | |
| try: | |
| inputs = build_inputs(audio_kwargs) | |
| except Exception: | |
| if not videos: | |
| raise | |
| log.exception("Video preprocessing with audio failed; retrying with frames only") | |
| inputs = build_inputs({}) | |
| with model_lock, torch.no_grad(): | |
| out = model.generate( | |
| **inputs, | |
| max_new_tokens=max_new_tokens, | |
| do_sample=False, | |
| return_dict_in_generate=True, | |
| ) | |
| start = inputs["input_ids"].shape[1] | |
| text = processor.decode( | |
| out.sequences[0, start:], | |
| skip_special_tokens=True, | |
| ).strip() | |
| # Safety net in case the model still emits a closing tag. | |
| if "</think>" in text: | |
| text = text.split("</think>", 1)[1].strip() | |
| return text | |
| # ---------- API ---------- | |
| app = FastAPI(title="Omni API") | |
| def require_key(authorization: str | None = Header(default=None)): | |
| if authorization != f"Bearer {API_KEY}": | |
| raise HTTPException(401, "Invalid or missing API key") | |
| class ChatRequest(BaseModel): | |
| model: str | None = None | |
| messages: list[dict] | |
| max_tokens: int | None = None | |
| temperature: float | None = None # accepted for compatibility; decoding is greedy | |
| def root(): | |
| return {"status": "ok", "model": SERVED_NAME} | |
| def list_models(): | |
| return { | |
| "object": "list", | |
| "data": [{"id": SERVED_NAME, "object": "model", "owned_by": "liquidai"}], | |
| } | |
| def chat_completions(req: ChatRequest): | |
| if not req.messages: | |
| raise HTTPException(400, "messages must not be empty") | |
| max_new = min(req.max_tokens or DEFAULT_MAX_TOKENS, HARD_MAX_TOKENS) | |
| with tempfile.TemporaryDirectory() as work: | |
| messages, has_video = convert_messages(req.messages, work) | |
| try: | |
| text = run_model(messages, has_video, max_new) | |
| except HTTPException: | |
| raise | |
| except Exception: | |
| log.exception("Generation failed") | |
| raise HTTPException(500, "Model failed to generate a response") | |
| return { | |
| "id": f"chatcmpl-{uuid.uuid4().hex}", | |
| "object": "chat.completion", | |
| "created": int(time.time()), | |
| "model": SERVED_NAME, | |
| "choices": [ | |
| { | |
| "index": 0, | |
| "message": {"role": "assistant", "content": text or "(empty response)"}, | |
| "finish_reason": "stop", | |
| } | |
| ], | |
| } | |
| # ---------- Gradio page (for humans) ---------- | |
| demo = gr.Blocks(title="Omni API") | |
| with demo: | |
| gr.Markdown( | |
| f"## Omni API\n" | |
| f"Model: `{MODEL_ID}`\n\n" | |
| f"OpenAI-compatible endpoint: `POST /v1/chat/completions` " | |
| f"(requires `Authorization: Bearer <key>`)." | |
| ) | |
| app = gr.mount_gradio_app(app, demo, path="/ui") | |
| if __name__ == "__main__": | |
| uvicorn.run(app, host="0.0.0.0", port=7860) |