Coverage for agentos/multimodal/provider.py: 0%
179 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-08 20:40 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-08 20:40 +0800
1"""
2v1.10.0: Multimodal Provider — Vision & Audio abstraction layer.
4Supports:
5- VisionProvider: image→text (base class + OpenAI/VLLM adapters)
6- AudioProvider: TTS + STT (base class + adapters)
7- MultiModalMessage: unified multimodal message format
8"""
10from __future__ import annotations
12import base64
13from dataclasses import dataclass, field
14from enum import StrEnum
15from pathlib import Path
16from typing import Any, Protocol, runtime_checkable
18# ── Enums & Data Classes ──────────────────────────────────────────
21class Modality(StrEnum):
22 TEXT = "text"
23 IMAGE = "image"
24 AUDIO = "audio"
25 VIDEO = "video"
28class ImageFormat(StrEnum):
29 PNG = "png"
30 JPEG = "jpeg"
31 WEBP = "webp"
32 GIF = "gif"
33 SVG = "svg"
36class AudioFormat(StrEnum):
37 MP3 = "mp3"
38 WAV = "wav"
39 OGG = "ogg"
40 FLAC = "flac"
41 AAC = "aac"
44@dataclass
45class MultiModalContent:
46 """A piece of multimodal content."""
48 type: Modality
49 text: str = ""
50 data: bytes = field(default=b"", repr=False)
51 data_url: str = "" # data:image/png;base64,...
52 mime_type: str = ""
53 metadata: dict[str, Any] = field(default_factory=dict)
55 @staticmethod
56 def from_text(content: str) -> MultiModalContent:
57 return MultiModalContent(type=Modality.TEXT, text=content)
59 @staticmethod
60 def from_path(path: str | Path) -> MultiModalContent:
61 path = Path(path)
62 data = path.read_bytes()
63 ext = path.suffix.lower().lstrip(".")
64 fmt_map = {
65 "png": "image/png",
66 "jpg": "image/jpeg",
67 "jpeg": "image/jpeg",
68 "webp": "image/webp",
69 "gif": "image/gif",
70 "mp3": "audio/mpeg",
71 "wav": "audio/wav",
72 "ogg": "audio/ogg",
73 "flac": "audio/flac",
74 }
75 mime = fmt_map.get(ext, "application/octet-stream")
76 b64 = base64.b64encode(data).decode()
77 modality = (
78 Modality.IMAGE
79 if mime.startswith("image/")
80 else (Modality.AUDIO if mime.startswith("audio/") else Modality.TEXT)
81 )
82 return MultiModalContent(
83 type=modality,
84 data=data,
85 data_url=f"data:{mime};base64,{b64}",
86 mime_type=mime,
87 )
89 @staticmethod
90 def from_bytes(data: bytes, mime_type: str = "image/png") -> MultiModalContent:
91 b64 = base64.b64encode(data).decode()
92 modality = (
93 Modality.IMAGE
94 if "image" in mime_type
95 else (Modality.AUDIO if "audio" in mime_type else Modality.TEXT)
96 )
97 return MultiModalContent(
98 type=modality,
99 data=data,
100 data_url=f"data:{mime_type};base64,{b64}",
101 mime_type=mime_type,
102 )
105@dataclass
106class MultiModalMessage:
107 """A multimodal message with mixed content blocks."""
109 role: str = "user" # system / user / assistant
110 content: list[MultiModalContent] = field(default_factory=list)
111 metadata: dict[str, Any] = field(default_factory=dict)
113 def add_text(self, text: str) -> MultiModalMessage:
114 self.content.append(MultiModalContent.from_text(text))
115 return self
117 def add_image_path(self, path: str | Path) -> MultiModalMessage:
118 self.content.append(MultiModalContent.from_path(path))
119 return self
121 def add_audio_path(self, path: str | Path) -> MultiModalMessage:
122 self.content.append(MultiModalContent.from_path(path))
123 return self
125 def to_openai_format(self) -> dict[str, Any]:
126 """Convert to OpenAI chat completion message format."""
127 blocks: list[dict[str, Any]] = []
128 for c in self.content:
129 if c.type == Modality.TEXT:
130 blocks.append({"type": "text", "text": c.text})
131 elif c.type == Modality.IMAGE:
132 blocks.append(
133 {
134 "type": "image_url",
135 "image_url": {"url": c.data_url, "detail": "auto"},
136 }
137 )
138 elif c.type == Modality.AUDIO:
139 blocks.append(
140 {
141 "type": "input_audio",
142 "input_audio": {
143 "data": base64.b64encode(c.data).decode(),
144 "format": c.mime_type.split("/")[-1] if c.mime_type else "wav",
145 },
146 }
147 )
148 return {"role": self.role, "content": blocks}
150 def to_gemini_format(self) -> dict[str, Any]:
151 """Convert to Gemini API message format."""
152 parts: list[dict[str, Any]] = []
153 for c in self.content:
154 if c.type == Modality.TEXT:
155 parts.append({"text": c.text})
156 elif c.type == Modality.IMAGE:
157 parts.append(
158 {
159 "inline_data": {
160 "mime_type": c.mime_type or "image/png",
161 "data": base64.b64encode(c.data).decode(),
162 }
163 }
164 )
165 elif c.type == Modality.AUDIO:
166 parts.append(
167 {
168 "inline_data": {
169 "mime_type": c.mime_type or "audio/wav",
170 "data": base64.b64encode(c.data).decode(),
171 }
172 }
173 )
174 return {"role": "user" if self.role == "user" else "model", "parts": parts}
177# ── Vision Provider ───────────────────────────────────────────────
180@runtime_checkable
181class VisionProvider(Protocol):
182 """Protocol for vision providers (image → text)."""
184 async def describe(self, image: MultiModalContent, prompt: str = "") -> str:
185 """Describe an image. Returns text description."""
186 ...
188 async def ask(self, images: list[MultiModalContent], question: str) -> str:
189 """Ask a question about one or more images."""
190 ...
193class OpenAIVisionProvider:
194 """OpenAI GPT-4V / GPT-4o vision provider."""
196 def __init__(self, api_key: str = "", model: str = "gpt-4o", base_url: str = ""):
197 self.api_key = api_key
198 self.model = model
199 self.base_url = base_url
201 async def describe(self, image: MultiModalContent, prompt: str = "") -> str:
202 return await self.ask([image], prompt or "Describe this image in detail.")
204 async def ask(self, images: list[MultiModalContent], question: str) -> str:
205 import aiohttp
207 message = MultiModalMessage(role="user")
208 for img in images:
209 message.content.append(img)
210 message.add_text(question)
211 body = message.to_openai_format()
213 payload = {
214 "model": self.model,
215 "messages": [
216 {"role": "system", "content": "You are a helpful vision assistant."},
217 body,
218 ],
219 "max_tokens": 1024,
220 }
222 url = (
223 f"{self.base_url}/chat/completions"
224 if self.base_url
225 else "https://api.openai.com/v1/chat/completions"
226 )
227 headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}
229 async with aiohttp.ClientSession() as session:
230 async with session.post(url, json=payload, headers=headers) as resp:
231 result = await resp.json()
232 return result["choices"][0]["message"]["content"]
235class LocalVisionProvider:
236 """Local vision provider (placeholder for vLLM/Ollama)."""
238 def __init__(self, endpoint: str = "http://localhost:11434", model: str = "llava"):
239 self.endpoint = endpoint
240 self.model = model
242 async def describe(self, image: MultiModalContent, prompt: str = "") -> str:
243 return await self.ask([image], prompt or "Describe this image.")
245 async def ask(self, images: list[MultiModalContent], question: str) -> str:
246 import aiohttp
248 async with aiohttp.ClientSession() as session:
249 async with session.post(
250 f"{self.endpoint}/api/generate",
251 json={
252 "model": self.model,
253 "prompt": question,
254 "images": [img.data_url.split(",", 1)[1] for img in images if img.data_url],
255 "stream": False,
256 },
257 ) as resp:
258 result = await resp.json()
259 return result.get("response", "")
262# ── Audio Provider ─────────────────────────────────────────────────
265@runtime_checkable
266class AudioProvider(Protocol):
267 """Protocol for audio providers (TTS + STT)."""
269 async def transcribe(self, audio: MultiModalContent, language: str = "") -> str:
270 """Speech-to-text: transcribe audio to text."""
271 ...
273 async def synthesize(
274 self, text: str, voice: str = "alloy", speed: float = 1.0
275 ) -> MultiModalContent:
276 """Text-to-speech: generate audio from text."""
277 ...
280class OpenAIAudioProvider:
281 """OpenAI Whisper + TTS audio provider."""
283 def __init__(self, api_key: str = "", tts_model: str = "tts-1", stt_model: str = "whisper-1"):
284 self.api_key = api_key
285 self.tts_model = tts_model
286 self.stt_model = stt_model
288 async def transcribe(self, audio: MultiModalContent, language: str = "") -> str:
289 import aiohttp
291 form = aiohttp.FormData()
292 form.add_field("model", self.stt_model)
293 form.add_field(
294 "file",
295 audio.data,
296 filename=f"audio.{audio.mime_type.split('/')[-1] or 'wav'}",
297 content_type=audio.mime_type or "audio/wav",
298 )
299 if language:
300 form.add_field("language", language)
302 headers = {"Authorization": f"Bearer {self.api_key}"}
303 async with aiohttp.ClientSession() as session:
304 async with session.post(
305 "https://api.openai.com/v1/audio/transcriptions", data=form, headers=headers
306 ) as resp:
307 result = await resp.json()
308 return result.get("text", "")
310 async def synthesize(
311 self, text: str, voice: str = "alloy", speed: float = 1.0
312 ) -> MultiModalContent:
313 import aiohttp
315 payload = {
316 "model": self.tts_model,
317 "input": text,
318 "voice": voice,
319 "speed": speed,
320 "response_format": "mp3",
321 }
322 headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}
323 async with aiohttp.ClientSession() as session:
324 async with session.post(
325 "https://api.openai.com/v1/audio/speech", json=payload, headers=headers
326 ) as resp:
327 audio_data = await resp.read()
328 return MultiModalContent.from_bytes(audio_data, "audio/mpeg")
331class EdgeTTSProvider:
332 """Microsoft Edge TTS (free, local)."""
334 def __init__(self, voice: str = "zh-CN-XiaoxiaoNeural"):
335 self.voice = voice
337 async def synthesize(self, text: str, voice: str = "", speed: float = 1.0) -> MultiModalContent:
338 import edge_tts # type: ignore[import-untyped]
340 voice_name = voice or self.voice
341 rate = f"{int((speed - 1.0) * 100):+d}%"
342 communicate = edge_tts.Communicate(text, voice_name, rate=rate)
343 audio_chunks = []
344 async for chunk in communicate.stream():
345 if chunk["type"] == "audio":
346 audio_chunks.append(chunk["data"])
347 audio_data = b"".join(audio_chunks)
348 return MultiModalContent.from_bytes(audio_data, "audio/mpeg")
351# ── MultiModal Client ─────────────────────────────────────────────
354class MultiModalClient:
355 """Unified multimodal client: vision + audio in one interface."""
357 def __init__(
358 self,
359 vision: VisionProvider | None = None,
360 audio: AudioProvider | None = None,
361 ):
362 self.vision = vision or LocalVisionProvider()
363 self.audio = audio
365 async def see(self, image_path: str | Path, question: str = "What's in this image?") -> str:
366 """Look at an image and answer a question about it."""
367 img = MultiModalContent.from_path(image_path)
368 return await self.vision.ask([img], question)
370 async def hear(self, audio_path: str | Path, language: str = "") -> str:
371 """Transcribe audio to text."""
372 if not self.audio:
373 raise RuntimeError("No audio provider configured")
374 audio = MultiModalContent.from_path(audio_path)
375 return await self.audio.transcribe(audio, language)
377 async def speak(self, text: str, voice: str = "alloy", speed: float = 1.0) -> MultiModalContent:
378 """Generate speech from text."""
379 if not self.audio:
380 raise RuntimeError("No audio provider configured")
381 return await self.audio.synthesize(text, voice, speed)