response.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. from __future__ import annotations
  2. import re
  3. import base64
  4. from typing import Union, Dict, List, Optional
  5. from abc import abstractmethod
  6. from urllib.parse import quote_plus, unquote_plus
  7. def quote_url(url: str) -> str:
  8. """
  9. Quote parts of a URL while preserving the domain structure.
  10. Args:
  11. url: The URL to quote
  12. Returns:
  13. str: The properly quoted URL
  14. """
  15. # Only unquote if needed to avoid double-unquoting
  16. if '%' in url:
  17. url = unquote_plus(url)
  18. url_parts = url.split("//", maxsplit=1)
  19. # If there is no "//" in the URL, then it is a relative URL
  20. if len(url_parts) == 1:
  21. return quote_plus(url_parts[0], '/?&=#')
  22. protocol, rest = url_parts
  23. domain_parts = rest.split("/", maxsplit=1)
  24. # If there is no "/" after the domain, then it is a domain URL
  25. if len(domain_parts) == 1:
  26. return f"{protocol}//{domain_parts[0]}"
  27. domain, path = domain_parts
  28. return f"{protocol}//{domain}/{quote_plus(path, '/?&=#')}"
  29. def quote_title(title: str) -> str:
  30. """
  31. Normalize whitespace in a title.
  32. Args:
  33. title: The title to normalize
  34. Returns:
  35. str: The title with normalized whitespace
  36. """
  37. return " ".join(title.split()) if title else ""
  38. def format_link(url: str, title: Optional[str] = None) -> str:
  39. """
  40. Format a URL and title as a markdown link.
  41. Args:
  42. url: The URL to link to
  43. title: The title to display. If None, extracts from URL
  44. Returns:
  45. str: The formatted markdown link
  46. """
  47. if title is None:
  48. try:
  49. title = unquote_plus(url.split("//", maxsplit=1)[1].split("?")[0].replace("www.", ""))
  50. except IndexError:
  51. title = url
  52. return f"[{quote_title(title)}]({quote_url(url)})"
  53. def format_image(image: str, alt: str, preview: Optional[str] = None) -> str:
  54. """
  55. Formats the given image as a markdown string.
  56. Args:
  57. image: The image to format.
  58. alt: The alt text for the image.
  59. preview: The preview URL format. Defaults to the original image.
  60. Returns:
  61. str: The formatted markdown string.
  62. """
  63. preview_url = preview.replace('{image}', image) if preview else image
  64. return f"[![{quote_title(alt)}]({quote_url(preview_url)})]({quote_url(image)})"
  65. def format_images_markdown(images: Union[str, List[str]], alt: str,
  66. preview: Union[str, List[str]] = None) -> str:
  67. """
  68. Formats the given images as a markdown string.
  69. Args:
  70. images: The image or list of images to format.
  71. alt: The alt text for the images.
  72. preview: The preview URL format or list of preview URLs.
  73. If not provided, original images are used.
  74. Returns:
  75. str: The formatted markdown string.
  76. """
  77. if isinstance(images, list) and len(images) == 1:
  78. images = images[0]
  79. if isinstance(images, str):
  80. result = format_image(images, alt, preview)
  81. else:
  82. result = "\n".join(
  83. format_image(
  84. image,
  85. f"#{idx+1} {alt}",
  86. preview[idx] if isinstance(preview, list) and idx < len(preview) else preview
  87. )
  88. for idx, image in enumerate(images)
  89. )
  90. start_flag = "<!-- generated images start -->\n"
  91. end_flag = "<!-- generated images end -->\n"
  92. return f"\n{start_flag}{result}\n{end_flag}\n"
  93. class ResponseType:
  94. @abstractmethod
  95. def __str__(self) -> str:
  96. """Convert the response to a string representation."""
  97. raise NotImplementedError
  98. class JsonMixin:
  99. def __init__(self, **kwargs) -> None:
  100. """Initialize with keyword arguments as attributes."""
  101. for key, value in kwargs.items():
  102. setattr(self, key, value)
  103. def get_dict(self) -> Dict:
  104. """Return a dictionary of non-private attributes."""
  105. return {
  106. key: value
  107. for key, value in self.__dict__.items()
  108. if not key.startswith("__")
  109. }
  110. def reset(self) -> None:
  111. """Reset all attributes."""
  112. self.__dict__ = {}
  113. class RawResponse(ResponseType, JsonMixin):
  114. pass
  115. class HiddenResponse(ResponseType):
  116. def __str__(self) -> str:
  117. """Hidden responses return an empty string."""
  118. return ""
  119. class FinishReason(JsonMixin, HiddenResponse):
  120. def __init__(self, reason: str) -> None:
  121. """Initialize with a reason."""
  122. self.reason = reason
  123. class ToolCalls(HiddenResponse):
  124. def __init__(self, list: List) -> None:
  125. """Initialize with a list of tool calls."""
  126. self.list = list
  127. def get_list(self) -> List:
  128. """Return the list of tool calls."""
  129. return self.list
  130. class Usage(JsonMixin, HiddenResponse):
  131. pass
  132. class AuthResult(JsonMixin, HiddenResponse):
  133. pass
  134. class TitleGeneration(HiddenResponse):
  135. def __init__(self, title: str) -> None:
  136. """Initialize with a title."""
  137. self.title = title
  138. class DebugResponse(HiddenResponse):
  139. def __init__(self, log: str) -> None:
  140. """Initialize with a log message."""
  141. self.log = log
  142. class Reasoning(ResponseType):
  143. def __init__(
  144. self,
  145. token: Optional[str] = None,
  146. status: Optional[str] = None,
  147. is_thinking: Optional[str] = None
  148. ) -> None:
  149. """Initialize with token, status, and thinking state."""
  150. self.token = token
  151. self.status = status
  152. self.is_thinking = is_thinking
  153. def __str__(self) -> str:
  154. """Return string representation based on available attributes."""
  155. if self.is_thinking is not None:
  156. return self.is_thinking
  157. if self.token is not None:
  158. return self.token
  159. if self.status is not None:
  160. return f"{self.status}\n"
  161. return ""
  162. def __eq__(self, other: Reasoning):
  163. return (self.token == other.token and
  164. self.status == other.status and
  165. self.is_thinking == other.is_thinking)
  166. def get_dict(self) -> Dict:
  167. """Return a dictionary representation of the reasoning."""
  168. if self.is_thinking is None:
  169. if self.status is None:
  170. return {"token": self.token}
  171. return {"token": self.token, "status": self.status}
  172. return {"token": self.token, "status": self.status, "is_thinking": self.is_thinking}
  173. class Sources(ResponseType):
  174. def __init__(self, sources: List[Dict[str, str]]) -> None:
  175. """Initialize with a list of source dictionaries."""
  176. self.list = []
  177. for source in sources:
  178. self.add_source(source)
  179. def add_source(self, source: Union[Dict[str, str], str]) -> None:
  180. """Add a source to the list, cleaning the URL if necessary."""
  181. source = source if isinstance(source, dict) else {"url": source}
  182. url = source.get("url", source.get("link", None))
  183. if url is not None:
  184. url = re.sub(r"[&?]utm_source=.+", "", url)
  185. source["url"] = url
  186. self.list.append(source)
  187. def __str__(self) -> str:
  188. """Return formatted sources as a string."""
  189. if not self.list:
  190. return ""
  191. return "\n\n\n\n" + ("\n>\n".join([
  192. f"> [{idx}] {format_link(link['url'], link.get('title', None))}"
  193. for idx, link in enumerate(self.list)
  194. ]))
  195. class YouTube(HiddenResponse):
  196. def __init__(self, ids: List[str]) -> None:
  197. """Initialize with a list of YouTube IDs."""
  198. self.ids = ids
  199. def to_string(self) -> str:
  200. """Return YouTube embeds as a string."""
  201. if not self.ids:
  202. return ""
  203. return "\n\n" + ("\n".join([
  204. f'<iframe type="text/html" src="https://www.youtube.com/embed/{id}"></iframe>'
  205. for id in self.ids
  206. ]))
  207. class Audio(ResponseType):
  208. def __init__(self, data: bytes) -> None:
  209. """Initialize with audio data bytes."""
  210. self.data = data
  211. def __str__(self) -> str:
  212. """Return audio data as a base64-encoded data URI."""
  213. data_base64 = base64.b64encode(self.data).decode()
  214. return f"data:audio/mpeg;base64,{data_base64}"
  215. class BaseConversation(ResponseType):
  216. def __str__(self) -> str:
  217. """Return an empty string by default."""
  218. return ""
  219. class JsonConversation(BaseConversation, JsonMixin):
  220. pass
  221. class SynthesizeData(HiddenResponse, JsonMixin):
  222. def __init__(self, provider: str, data: Dict) -> None:
  223. """Initialize with provider and data."""
  224. self.provider = provider
  225. self.data = data
  226. class RequestLogin(HiddenResponse):
  227. def __init__(self, label: str, login_url: str) -> None:
  228. """Initialize with label and login URL."""
  229. self.label = label
  230. self.login_url = login_url
  231. def to_string(self) -> str:
  232. """Return formatted login link as a string."""
  233. return format_link(self.login_url, f"[Login to {self.label}]") + "\n\n"
  234. class ImageResponse(ResponseType):
  235. def __init__(
  236. self,
  237. images: Union[str, List[str]],
  238. alt: str,
  239. options: Dict = {}
  240. ) -> None:
  241. """Initialize with images, alt text, and options."""
  242. self.images = images
  243. self.alt = alt
  244. self.options = options
  245. def __str__(self) -> str:
  246. """Return images as markdown."""
  247. return format_images_markdown(self.images, self.alt, self.get("preview"))
  248. def get(self, key: str) -> any:
  249. """Get an option value by key."""
  250. return self.options.get(key)
  251. def get_list(self) -> List[str]:
  252. """Return images as a list."""
  253. return [self.images] if isinstance(self.images, str) else self.images
  254. class ImagePreview(ImageResponse):
  255. def __str__(self) -> str:
  256. """Return an empty string for preview."""
  257. return ""
  258. def to_string(self) -> str:
  259. """Return images as markdown."""
  260. return super().__str__()
  261. class PreviewResponse(HiddenResponse):
  262. def __init__(self, data: str) -> None:
  263. """Initialize with data."""
  264. self.data = data
  265. def to_string(self) -> str:
  266. """Return data as a string."""
  267. return self.data
  268. class Parameters(ResponseType, JsonMixin):
  269. def __str__(self) -> str:
  270. """Return an empty string."""
  271. return ""
  272. class ProviderInfo(JsonMixin, HiddenResponse):
  273. pass