"""GovernedVLMClient -- VLMProvider 最小可用实现。 将图片编码为 base64,构造 OpenAI Vision API 格式的 messages, 委托给已有的 GovernedLLMClient 发送。复用 LLM 治理栈的全部能力 (熔断、缓存、重试、遥测)。 """ from __future__ import annotations import base64 import mimetypes from pathlib import Path from typing import TYPE_CHECKING, Any from loguru import logger if TYPE_CHECKING: from adapters.llm import GovernedLLMClient from core.types import LLMResponse class GovernedVLMClient: """VLMProvider 实现——包装 GovernedLLMClient,注入 base64 图片。 参数: governed_llm: 已初始化的 GovernedLLMClient 实例。 """ def __init__(self, governed_llm: GovernedLLMClient) -> None: self._llm = governed_llm async def chat_with_images( self, messages: list[dict[str, Any]], images: list[str | Path], *, session_id: str | None = None, parent_call_id: str | None = None, ) -> LLMResponse: """图文调用:将图片编码为 base64 嵌入 messages,委托给 LLM 客户端。 参数: messages: 对话消息列表。最后一条 user message 的 content 会被扩展为 包含图片的多模态格式。 images: 图片文件路径列表。 session_id: 会话 ID(遥测用)。 parent_call_id: 父调用 ID(遥测用)。 返回: LLMResponse。 """ vision_messages = self._inject_images(messages, images) return await self._llm.chat( vision_messages, session_id=session_id, parent_call_id=parent_call_id, ) @staticmethod def _encode_image(image_path: str | Path) -> str: """将图片文件编码为 base64 data URL。 参数: image_path: 图片文件路径。 返回: data:image/;base64, 格式的字符串。 """ path = Path(image_path) mime_type = mimetypes.guess_type(str(path))[0] or "image/jpeg" with open(path, "rb") as f: b64 = base64.b64encode(f.read()).decode("utf-8") return f"data:{mime_type};base64,{b64}" @staticmethod def _inject_images( messages: list[dict[str, Any]], images: list[str | Path], ) -> list[dict[str, Any]]: """将图片注入最后一条 user message,构造 OpenAI Vision API 格式。 参数: messages: 原始消息列表。 images: 图片路径列表。 返回: 新消息列表(不修改原列表)。 """ if not images: return messages result = [m.copy() for m in messages] # 找到最后一条 user message last_user_idx = -1 for i in range(len(result) - 1, -1, -1): if result[i].get("role") == "user": last_user_idx = i break if last_user_idx == -1: logger.warning("messages 中无 user 角色消息,图片未注入") return result user_msg = result[last_user_idx] original_content = user_msg.get("content", "") # 构造多模态 content content_parts: list[dict[str, Any]] = [] # 图片在前 for img_path in images: data_url = GovernedVLMClient._encode_image(img_path) content_parts.append( { "type": "image_url", "image_url": {"url": data_url}, } ) # 文本在后 if isinstance(original_content, str) and original_content: content_parts.append({"type": "text", "text": original_content}) elif isinstance(original_content, list): content_parts.extend(original_content) result[last_user_idx] = {**user_msg, "content": content_parts} return result