"""structured output 공용 래퍼. OpenAI 호환 /chat/completions를 쓰는 프로바이더면 base_url만 바꿔 그대로 쓴다. 이미지 인코딩은 호출측 책임이라 여기서는 URL(data URI 또는 http)만 받는다. """ from collections.abc import Sequence from typing import TypeVar from openai import AsyncOpenAI from pydantic import BaseModel T = TypeVar("T", bound=BaseModel) class StructuredLLM: def __init__(self, model: str, api_key: str, base_url: str | None = None): self.model = model self._client = AsyncOpenAI(api_key=api_key, base_url=base_url) async def ask( self, schema: type[T], prompt: str, *, system: str | None = None, temperature: float = 0.0, ) -> T: return await self._parse(schema, prompt, system, temperature) async def ask_with_images( self, schema: type[T], prompt: str, images: Sequence[tuple[str, str]], *, detail: str = "high", system: str | None = None, temperature: float = 0.0, ) -> T: """images는 (라벨, 이미지 URL) 쌍. 라벨이 이미지 바로 앞에 붙어 순서가 보존된다.""" content: list[dict] = [{"type": "text", "text": prompt}] for label, url in images: content.append({"type": "text", "text": label}) content.append({"type": "image_url", "image_url": {"url": url, "detail": detail}}) return await self._parse(schema, content, system, temperature) async def _parse(self, schema: type[T], content, system: str | None, temperature: float) -> T: messages = [{"role": "system", "content": system}] if system else [] messages.append({"role": "user", "content": content}) msg = (await self._client.chat.completions.parse( model=self.model, messages=messages, response_format=schema, temperature=temperature, )).choices[0].message if msg.refusal: raise RuntimeError(f"{self.model} 거절: {msg.refusal}") if msg.parsed is None: raise RuntimeError(f"{self.model} 파싱 실패: {msg.content!r}") return msg.parsed