import json from dataclasses import dataclass from urllib import request from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Union from approach.config import ProviderConfig PostFn = Callable[..., Any] @dataclass(frozen=True) class EncodedImage: data: str media_type: str = "image/jpeg" class UrlLibResponse: def __init__(self, status: int, body: bytes): self.status_code = status self._body = body def raise_for_status(self): if self.status_code >= 400: raise RuntimeError(f"HTTP request failed with status {self.status_code}") def json(self): return json.loads(self._body.decode("utf-8")) def default_post(url: str, headers: Mapping[str, str], json: Mapping[str, Any], timeout: int): req = request.Request( url, data=json_dumps_bytes(json), headers=dict(headers), method="POST", ) with request.urlopen(req, timeout=timeout) as response: return UrlLibResponse(response.status, response.read()) def json_dumps_bytes(payload: Mapping[str, Any]) -> bytes: return json.dumps(payload).encode("utf-8") def make_multimodal_content( text: str, image_b64s: Optional[Iterable[Union[str, EncodedImage]]] = None, ) -> List[Dict[str, Any]]: content = [{"type": "text", "text": text}] for image in image_b64s or []: encoded = image if isinstance(image, EncodedImage) else EncodedImage(data=image) content.append( { "type": "image_url", "image_url": { "url": f"data:{encoded.media_type};base64,{encoded.data}" }, } ) return content class OpenAICompatibleChatClient: def __init__(self, profile: ProviderConfig, post: Optional[PostFn] = None): self.profile = profile self.post = post or default_post def build_payload( self, prompt: str, image_b64s: Optional[Iterable[Union[str, EncodedImage]]] = None, response_format: Optional[Mapping[str, Any]] = None, temperature: float = 0, max_tokens: int = 4096, ) -> Dict[str, Any]: return self.build_messages_payload( [ { "role": "user", "content": make_multimodal_content(prompt, image_b64s), } ], response_format=response_format, temperature=temperature, max_tokens=max_tokens, ) def build_messages_payload( self, messages: Iterable[Mapping[str, Any]], response_format: Optional[Mapping[str, Any]] = None, temperature: float = 0, max_tokens: int = 4096, ) -> Dict[str, Any]: payload: Dict[str, Any] = { "model": self.profile.model, "temperature": temperature, "messages": [dict(message) for message in messages], "max_tokens": max_tokens, } if response_format: payload["response_format"] = dict(response_format) if self.profile.router_options: payload["provider"] = dict(self.profile.router_options) return payload def chat_completion(self, payload: Mapping[str, Any]) -> Mapping[str, Any]: if not self.profile.api_key: raise RuntimeError(f"Missing API key in environment variable {self.profile.api_key_env}") headers = { "Content-Type": "application/json", "Authorization": f"Bearer {self.profile.api_key}", } response = self.post( f"{self.profile.base_url.rstrip('/')}/chat/completions", headers=headers, json=dict(payload), timeout=120, ) response.raise_for_status() return response.json() def complete_json( self, prompt: str, image_b64s: Optional[Iterable[Union[str, EncodedImage]]] = None, response_format: Optional[Mapping[str, Any]] = None, ) -> Any: payload = self.build_payload(prompt, image_b64s, response_format=response_format) return self.complete_json_payload(payload) def complete_json_messages( self, messages: Iterable[Mapping[str, Any]], response_format: Optional[Mapping[str, Any]] = None, ) -> Any: payload = self.build_messages_payload( messages, response_format=response_format, ) return self.complete_json_payload(payload) def complete_json_payload(self, payload: Mapping[str, Any]) -> Any: response = self.chat_completion(payload) content = response["choices"][0]["message"]["content"].strip() if content.startswith("```"): content = content.strip("`").split("\n", 1)[-1] return json.loads(content)