Spaces:
Running on Zero
Running on Zero
agharsallah
feat(media): enhance media request handling with timeout configuration and improved serialization
846eb30 | """Media providers β the image/speech analogue of :mod:`src.models.provider`. | |
| Two capabilities, each with the same two implementations as the text layer: a | |
| deterministic offline **stub** (so the no-key demo and the zero-mock tests stay whole) | |
| and a **live** OpenAI-compatible HTTP provider (an image-generations / audio-speech | |
| endpoint served on Modal, ADR-0015). A :class:`MediaResult` mirrors a model provider's | |
| ``model_id`` + ``last_usage`` so a media call is attributable and meterable like any | |
| other call. Routing (offline vs live, which endpoint) lives in :mod:`src.media.router`. | |
| """ | |
| from __future__ import annotations | |
| import base64 | |
| import os | |
| from abc import ABC, abstractmethod | |
| from dataclasses import dataclass, field | |
| from src import observability as obs | |
| from src.media.stub_image import draw_forest | |
| from src.media.stub_speech import chime | |
| # A consistent look for the live image model β the engine stays clean; the prompt | |
| # engineering rides here, next to the call that uses it. | |
| _IMAGE_STYLE = "a whimsical painterly storybook illustration, soft phosphor glow, no text:" | |
| def _media_timeout() -> float: | |
| """Per-call wall-clock budget for a live media request (env ``MEDIA_TIMEOUT_S``). | |
| Media is best-effort garnish: a down or crash-looping endpoint must fail FAST and | |
| let the beat degrade to text, never block the synchronous turn. The OpenAI SDK | |
| otherwise defaults to a 600s timeout with 2 retries (~30 min worst case), which reads | |
| as the whole show hanging. We cap it and disable retries. Generous enough for a warm | |
| few-step image / short TTS clip; tune up if a warm call legitimately needs longer.""" | |
| try: | |
| return max(5.0, float(os.getenv("MEDIA_TIMEOUT_S", "120"))) | |
| except ValueError: | |
| return 120.0 | |
| class MediaResult: | |
| """One synthesized artifact plus its metering β the UI turns it into a ref.""" | |
| mime: str | |
| data: bytes | |
| model_id: str | |
| usage: dict[str, float] = field(default_factory=dict) | |
| meta: dict[str, str] = field(default_factory=dict) | |
| def data_uri(self) -> str: | |
| """A self-contained ``data:`` URI (the offline transport).""" | |
| return f"data:{self.mime};base64,{base64.b64encode(self.data).decode('ascii')}" | |
| class ImageProvider(ABC): | |
| def generate(self, prompt: str, *, style: str | None = None) -> MediaResult: ... | |
| class SpeechProvider(ABC): | |
| def synthesize(self, text: str, *, voice: str | None = None) -> MediaResult: ... | |
| # ββ offline stubs (deterministic, no network) ββββββββββββββββββββββββββββββββββ | |
| class StubImageProvider(ImageProvider): | |
| variant: str = "stub:image" | |
| def generate(self, prompt: str, *, style: str | None = None) -> MediaResult: | |
| with obs.span("media.call", **{"media.system": "stub", "media.kind": "image", "media.model": self.variant}): | |
| png = draw_forest(prompt, style) | |
| obs.incr("media.calls", 1, kind="image", model=self.variant) | |
| return MediaResult("image/png", png, self.variant, {"images": 1, "cost_usd": 0.0}, {"prompt": prompt[:200]}) | |
| class StubSpeechProvider(SpeechProvider): | |
| variant: str = "stub:speech" | |
| def synthesize(self, text: str, *, voice: str | None = None) -> MediaResult: | |
| with obs.span("media.call", **{"media.system": "stub", "media.kind": "speech", "media.model": self.variant}): | |
| wav, seconds = chime(text, voice) | |
| obs.incr("media.calls", 1, kind="speech", model=self.variant) | |
| return MediaResult( | |
| "audio/wav", | |
| wav, | |
| self.variant, | |
| {"audio_seconds": seconds, "cost_usd": 0.0}, | |
| {"voice": voice or "default"}, | |
| ) | |
| # ββ live OpenAI-compatible HTTP providers βββββββββββββββββββββββββββββββββββββββ | |
| class HTTPImageProvider(ImageProvider): | |
| """Calls ``POST {base_url}/images/generations`` via the OpenAI SDK (ADR-0015).""" | |
| model: str | |
| base_url: str | |
| api_key: str = "" | |
| size: str = "512x512" | |
| def generate(self, prompt: str, *, style: str | None = None) -> MediaResult: | |
| from openai import OpenAI | |
| framed = f"{(style or _IMAGE_STYLE).rstrip(':')}: {prompt}".strip() | |
| with obs.span("media.call", **{"media.system": "openai", "media.kind": "image", "media.model": self.model}): | |
| # Bounded timeout, no retries: a failure degrades to a text-only beat fast (the | |
| # caller catches and skips media), never hanging the synchronous turn. | |
| client = OpenAI( | |
| base_url=self.base_url, api_key=self.api_key or "sk-noauth", timeout=_media_timeout(), max_retries=0 | |
| ) | |
| resp = client.images.generate( | |
| model=self.model, prompt=framed, size=self.size, n=1, response_format="b64_json" | |
| ) | |
| data = base64.b64decode(resp.data[0].b64_json or "") | |
| obs.incr("media.calls", 1, kind="image", model=self.model) | |
| return MediaResult("image/png", data, self.model, {"images": 1}, {"prompt": prompt[:200]}) | |
| class HTTPSpeechProvider(SpeechProvider): | |
| """Calls ``POST {base_url}/audio/speech`` via the OpenAI SDK (ADR-0015).""" | |
| model: str | |
| base_url: str | |
| api_key: str = "" | |
| voice: str = "default" | |
| def synthesize(self, text: str, *, voice: str | None = None) -> MediaResult: | |
| from openai import OpenAI | |
| with obs.span("media.call", **{"media.system": "openai", "media.kind": "speech", "media.model": self.model}): | |
| # Bounded timeout, no retries: a failure degrades the beat to text fast (the | |
| # caller catches and skips audio), never hanging the synchronous turn. | |
| client = OpenAI( | |
| base_url=self.base_url, api_key=self.api_key or "sk-noauth", timeout=_media_timeout(), max_retries=0 | |
| ) | |
| resp = client.audio.speech.create( | |
| model=self.model, input=text, voice=voice or self.voice, response_format="wav" | |
| ) | |
| data = resp.read() if hasattr(resp, "read") else getattr(resp, "content", b"") | |
| obs.incr("media.calls", 1, kind="speech", model=self.model) | |
| return MediaResult( | |
| "audio/wav", bytes(data), self.model, {"audio_seconds": 0.0}, {"voice": voice or self.voice} | |
| ) | |