mirascope 1.16.9__py3-none-any.whl → 1.17.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. mirascope/__init__.py +20 -1
  2. mirascope/core/anthropic/_utils/_convert_message_params.py +13 -0
  3. mirascope/core/azure/_utils/_convert_message_params.py +10 -0
  4. mirascope/core/azure/_utils/_message_param_converter.py +46 -12
  5. mirascope/core/base/__init__.py +4 -0
  6. mirascope/core/base/_utils/_convert_messages_to_message_params.py +36 -3
  7. mirascope/core/base/_utils/_parse_content_template.py +35 -9
  8. mirascope/core/base/message_param.py +30 -2
  9. mirascope/core/base/messages.py +10 -0
  10. mirascope/core/bedrock/_utils/_convert_message_params.py +18 -1
  11. mirascope/core/gemini/_utils/_convert_message_params.py +48 -5
  12. mirascope/core/gemini/_utils/_message_param_converter.py +51 -5
  13. mirascope/core/gemini/_utils/_setup_call.py +12 -2
  14. mirascope/core/groq/_utils/_convert_message_params.py +9 -0
  15. mirascope/core/groq/_utils/_message_param_converter.py +9 -2
  16. mirascope/core/mistral/_utils/_convert_message_params.py +7 -0
  17. mirascope/core/mistral/_utils/_message_param_converter.py +41 -35
  18. mirascope/core/openai/_utils/_convert_message_params.py +38 -1
  19. mirascope/core/openai/_utils/_message_param_converter.py +28 -4
  20. mirascope/core/vertex/_utils/_convert_message_params.py +56 -6
  21. mirascope/core/vertex/_utils/_message_param_converter.py +13 -5
  22. mirascope/core/vertex/_utils/_setup_call.py +10 -1
  23. mirascope/llm/call_response.py +5 -1
  24. mirascope/retries/__init__.py +5 -0
  25. mirascope/retries/fallback.py +128 -0
  26. {mirascope-1.16.9.dist-info → mirascope-1.17.0.dist-info}/METADATA +1 -1
  27. {mirascope-1.16.9.dist-info → mirascope-1.17.0.dist-info}/RECORD +29 -28
  28. {mirascope-1.16.9.dist-info → mirascope-1.17.0.dist-info}/WHEEL +0 -0
  29. {mirascope-1.16.9.dist-info → mirascope-1.17.0.dist-info}/licenses/LICENSE +0 -0
@@ -8,7 +8,14 @@ from google.generativeai.types import (
8
8
  )
9
9
 
10
10
  from mirascope.core import BaseMessageParam
11
- from mirascope.core.base import DocumentPart, ImagePart, TextPart
11
+ from mirascope.core.base import (
12
+ AudioPart,
13
+ AudioURLPart,
14
+ DocumentPart,
15
+ ImagePart,
16
+ ImageURLPart,
17
+ TextPart,
18
+ )
12
19
  from mirascope.core.base._utils._base_message_param_converter import (
13
20
  BaseMessageParamConverter,
14
21
  )
@@ -29,6 +36,28 @@ def _to_image_part(mime_type: str, data: bytes) -> ImagePart:
29
36
  return ImagePart(type="image", media_type=mime_type, image=data, detail=None)
30
37
 
31
38
 
39
+ def _is_audio_mime(mime_type: str) -> bool:
40
+ return mime_type in [
41
+ "audio/wav",
42
+ "audio/mp3",
43
+ "audio/wav",
44
+ "audio/mp3",
45
+ "audio/aiff",
46
+ "audio/aac",
47
+ "audio/ogg",
48
+ "audio/flac",
49
+ ]
50
+
51
+
52
+ def _to_audio_part(mime_type: str, data: bytes) -> AudioPart:
53
+ if not _is_audio_mime(mime_type):
54
+ raise ValueError(
55
+ f"Unsupported audio media type: {mime_type}. "
56
+ "Expected one of: audio/wav, audio/mp3, audio/aiff, audio/aac, audio/ogg, audio/flac."
57
+ )
58
+ return AudioPart(type="audio", media_type=mime_type, audio=data)
59
+
60
+
32
61
  def _to_document_part(mime_type: str, data: bytes) -> DocumentPart:
33
62
  if mime_type != "application/pdf":
34
63
  raise ValueError(
@@ -77,6 +106,8 @@ class GeminiMessageParamConverter(BaseMessageParamConverter):
77
106
  data = blob.data
78
107
  if _is_image_mime(mime):
79
108
  content_list.append(_to_image_part(mime, data))
109
+ elif _is_audio_mime(mime):
110
+ content_list.append(_to_audio_part(mime, data))
80
111
  elif mime == "application/pdf":
81
112
  content_list.append(_to_document_part(mime, data))
82
113
  else:
@@ -85,10 +116,25 @@ class GeminiMessageParamConverter(BaseMessageParamConverter):
85
116
  )
86
117
 
87
118
  elif part.file_data:
88
- # part.file_data.file_uri has Google storage URI like "gs://bucket_name/file_name"
89
- raise ValueError(
90
- f"FileData.file_uri is not support: {part.file_data}. Cannot convert to BaseMessageParam."
91
- )
119
+ if _is_image_mime(part.file_data.mime_type):
120
+ content_list.append(
121
+ ImageURLPart(
122
+ type="image_url",
123
+ url=part.file_data.file_uri,
124
+ detail=None,
125
+ )
126
+ )
127
+ elif _is_audio_mime(part.file_data.mime_type):
128
+ content_list.append(
129
+ AudioURLPart(
130
+ type="audio_url",
131
+ url=part.file_data.file_uri,
132
+ )
133
+ )
134
+ else:
135
+ raise ValueError(
136
+ f"Unsupported file_data mime type: {part.file_data.mime_type}. Cannot convert to BaseMessageParam."
137
+ )
92
138
  elif part.function_call:
93
139
  converted.append(
94
140
  BaseMessageParam(
@@ -11,7 +11,7 @@ from google.generativeai.types import (
11
11
  GenerateContentResponse,
12
12
  GenerationConfigDict,
13
13
  )
14
- from google.generativeai.types.content_types import ToolConfigDict
14
+ from google.generativeai.types.content_types import ToolConfigDict, to_content
15
15
  from pydantic import BaseModel
16
16
 
17
17
  from ...base import BaseMessageParam, BaseTool, _utils
@@ -108,6 +108,7 @@ def setup_call(
108
108
  call_kwargs = cast(GeminiCallKwargs, base_call_kwargs)
109
109
  messages = cast(list[BaseMessageParam | ContentDict], messages)
110
110
  messages = convert_message_params(messages)
111
+
111
112
  if json_mode:
112
113
  generation_config = call_kwargs.get("generation_config", {})
113
114
  if is_dataclass(generation_config):
@@ -125,11 +126,20 @@ def setup_call(
125
126
  "allowed_function_names": [tool_types[0]._name()],
126
127
  }
127
128
  call_kwargs["tool_config"] = tool_config
128
- call_kwargs |= {"contents": messages}
129
129
 
130
130
  if client is None:
131
131
  client = GenerativeModel(model_name=model)
132
132
 
133
+ if messages and messages[0]["role"] == "system":
134
+ system_instruction = client._system_instruction
135
+ system_instruction = (
136
+ list(system_instruction.parts) if system_instruction else []
137
+ )
138
+ system_instruction.extend(messages.pop(0)["parts"]) # pyright: ignore [reportArgumentType]
139
+ client._system_instruction = to_content(system_instruction) # pyright: ignore [reportArgumentType]
140
+
141
+ call_kwargs |= {"contents": messages}
142
+
133
143
  create = (
134
144
  get_async_create_fn(client.generate_content_async)
135
145
  if fn_is_async(fn)
@@ -45,6 +45,15 @@ def convert_message_params(
45
45
  },
46
46
  }
47
47
  )
48
+ elif part.type == "image_url":
49
+ converted_content.append(
50
+ {
51
+ "type": "image_url",
52
+ "image_url": {
53
+ "url": part.url,
54
+ },
55
+ }
56
+ )
48
57
  elif part.type == "tool_call":
49
58
  converted_message_param = {
50
59
  "role": "assistant",
@@ -8,7 +8,7 @@ from mirascope.core.base import TextPart, ToolResultPart
8
8
  from mirascope.core.base._utils._base_message_param_converter import (
9
9
  BaseMessageParamConverter,
10
10
  )
11
- from mirascope.core.base.message_param import ToolCallPart
11
+ from mirascope.core.base.message_param import ImageURLPart, ToolCallPart
12
12
  from mirascope.core.groq._utils import convert_message_params
13
13
 
14
14
 
@@ -70,7 +70,14 @@ class GroqMessageParamConverter(BaseMessageParamConverter):
70
70
  for part in content:
71
71
  if "text" in part:
72
72
  contents.append(TextPart(type="text", text=part["text"]))
73
- # TODO: add support for image parts here
73
+ elif "image_url" in part:
74
+ contents.append(
75
+ ImageURLPart(
76
+ type="image_url",
77
+ url=part["image_url"]["url"],
78
+ detail=part["image_url"].get("detail"),
79
+ )
80
+ )
74
81
  if contents:
75
82
  converted.append(
76
83
  BaseMessageParam(role=message_param["role"], content=contents)
@@ -69,6 +69,13 @@ def convert_message_params(
69
69
  )
70
70
  )
71
71
  )
72
+ elif part.type == "image_url":
73
+ converted_content.append(
74
+ {
75
+ "type": "image_url",
76
+ "image_url": part.url,
77
+ }
78
+ )
72
79
  elif part.type == "tool_call":
73
80
  converted_message_params.append(
74
81
  AssistantMessage(
@@ -19,7 +19,22 @@ from mirascope.core.base._utils._base_message_param_converter import (
19
19
  from mirascope.core.mistral._utils import convert_message_params
20
20
 
21
21
  from ...base import BaseMessageParam, ImagePart, TextPart, ToolResultPart
22
- from ...base.message_param import ToolCallPart
22
+ from ...base.message_param import ImageURLPart, ToolCallPart
23
+
24
+
25
+ def _create_image_part_from_data_url(image_url: str) -> ImagePart | None:
26
+ match = re.match(r"data:(image/\w+);base64,(.+)", image_url)
27
+ if not match:
28
+ return None
29
+ mime_type = match.group(1)
30
+ image_base64 = match.group(2)
31
+ image_data = base64.b64decode(image_base64)
32
+ return ImagePart(
33
+ type="image",
34
+ media_type=mime_type,
35
+ image=image_data,
36
+ detail=None,
37
+ )
23
38
 
24
39
 
25
40
  class MistralMessageParamConverter(BaseMessageParamConverter):
@@ -102,44 +117,35 @@ class MistralMessageParamConverter(BaseMessageParamConverter):
102
117
  elif isinstance(chunk, ImageURLChunk):
103
118
  image_url = chunk.image_url
104
119
  if isinstance(image_url, str):
105
- # Extract data from the data URL
106
- match = re.match(r"data:(image/\w+);base64,(.+)", image_url)
107
- if not match:
108
- raise ValueError(
109
- "ImageURLChunk image_url is not in a supported data URL format."
110
- )
111
- mime_type = match.group(1)
112
- image_base64 = match.group(2)
113
- image_data = base64.b64decode(image_base64)
114
- converted_parts.append(
115
- ImagePart(
116
- type="image",
117
- media_type=mime_type,
118
- image=image_data,
119
- detail=None,
120
+ if image_part := _create_image_part_from_data_url(
121
+ image_url
122
+ ):
123
+ converted_parts.append(image_part)
124
+ else:
125
+ converted_parts.append(
126
+ ImageURLPart(
127
+ type="image_url",
128
+ url=image_url,
129
+ detail=None,
130
+ )
120
131
  )
121
- )
132
+
122
133
  else:
123
134
  img_url_str = image_url.url # type: ignore
124
- match = re.match(
125
- r"data:(image/\w+);base64,(.+)", img_url_str
126
- )
127
- if not match:
128
- raise ValueError(
129
- "ImageURLChunk image_url is not in a supported data URL format."
130
- )
131
- mime_type = match.group(1)
132
- image_base64 = match.group(2)
133
- image_data = base64.b64decode(image_base64)
134
- converted_parts.append(
135
- ImagePart(
136
- type="image",
137
- media_type=mime_type,
138
- image=image_data,
139
- detail=None,
135
+ if image_part := _create_image_part_from_data_url(
136
+ img_url_str
137
+ ):
138
+ converted_parts.append(image_part)
139
+ else:
140
+ converted_parts.append(
141
+ ImageURLPart(
142
+ type="image_url",
143
+ url=img_url_str,
144
+ detail=image_url.detail
145
+ if isinstance(image_url.detail, str)
146
+ else None,
147
+ )
140
148
  )
141
- )
142
-
143
149
  elif isinstance(chunk, ReferenceChunk):
144
150
  raise ValueError(
145
151
  "ReferenceChunk is not supported for conversion to BaseMessageParam."
@@ -6,6 +6,8 @@ import json
6
6
  from openai.types.chat import ChatCompletionMessageParam
7
7
 
8
8
  from ...base import BaseMessageParam
9
+ from ...base._utils import get_audio_type
10
+ from ...base._utils._parse_content_template import _load_media
9
11
 
10
12
 
11
13
  def convert_message_params(
@@ -44,6 +46,16 @@ def convert_message_params(
44
46
  },
45
47
  }
46
48
  )
49
+ elif part.type == "image_url":
50
+ converted_content.append(
51
+ {
52
+ "type": "image_url",
53
+ "image_url": {
54
+ "url": part.url,
55
+ "detail": part.detail if part.detail else "auto",
56
+ },
57
+ }
58
+ )
47
59
  elif part.type == "audio":
48
60
  if part.media_type not in [
49
61
  "audio/wav",
@@ -53,11 +65,36 @@ def convert_message_params(
53
65
  f"Unsupported audio media type: {part.media_type}. "
54
66
  "OpenAI currently only supports WAV and MP3 audio file types."
55
67
  )
68
+ data = (
69
+ part.audio
70
+ if isinstance(part.audio, str)
71
+ else base64.b64encode(part.audio).decode("utf-8")
72
+ )
56
73
  converted_content.append(
57
74
  {
58
75
  "input_audio": {
59
76
  "format": part.media_type.split("/")[-1],
60
- "data": base64.b64encode(part.audio).decode("utf-8"),
77
+ "data": data,
78
+ },
79
+ "type": "input_audio",
80
+ }
81
+ )
82
+ elif part.type == "audio_url":
83
+ audio = _load_media(part.url)
84
+ audio_type = get_audio_type(audio)
85
+ if audio_type not in [
86
+ "audio/wav",
87
+ "audio/mp3",
88
+ ]:
89
+ raise ValueError(
90
+ f"Unsupported audio media type: {audio_type}. "
91
+ "OpenAI currently only supports WAV and MP3 audio file types."
92
+ )
93
+ converted_content.append(
94
+ {
95
+ "input_audio": {
96
+ "format": audio_type.split("/")[-1],
97
+ "data": base64.b64encode(audio).decode("utf-8"),
61
98
  },
62
99
  "type": "input_audio",
63
100
  }
@@ -1,12 +1,19 @@
1
1
  """This module contains the OpenAIMessageParamConverter class."""
2
2
 
3
3
  import json
4
+ from collections.abc import Iterable
4
5
  from typing import cast
5
6
 
6
7
  from openai.types.chat import ChatCompletionMessageParam
7
8
 
8
9
  from mirascope.core import BaseMessageParam
9
- from mirascope.core.base import TextPart, ToolCallPart, ToolResultPart
10
+ from mirascope.core.base import (
11
+ AudioPart,
12
+ ImageURLPart,
13
+ TextPart,
14
+ ToolCallPart,
15
+ ToolResultPart,
16
+ )
10
17
  from mirascope.core.base._utils._base_message_param_converter import (
11
18
  BaseMessageParamConverter,
12
19
  )
@@ -57,12 +64,29 @@ class OpenAIMessageParamConverter(BaseMessageParamConverter):
57
64
  BaseMessageParam(role=message_param["role"], content=content)
58
65
  )
59
66
  continue
60
- elif isinstance(content, list):
67
+ elif isinstance(content, Iterable):
61
68
  for part in content:
62
- if "text" in part:
69
+ if part["type"] == "text":
63
70
  contents.append(TextPart(type="text", text=part["text"]))
71
+ elif part["type"] == "image_url":
72
+ image_url = part["image_url"]
73
+ contents.append(
74
+ ImageURLPart(
75
+ type="image_url",
76
+ url=image_url["url"],
77
+ detail=image_url.get("detail", None),
78
+ )
79
+ )
80
+ elif part["type"] == "input_audio":
81
+ input_audio = part["input_audio"]
82
+ contents.append(
83
+ AudioPart(
84
+ type="audio",
85
+ media_type=f"audio/{input_audio['format']}",
86
+ audio=input_audio["data"],
87
+ )
88
+ )
64
89
  else:
65
- # TODO: add support for image and audio parts here
66
90
  raise ValueError(part["refusal"]) # pyright: ignore [reportGeneralTypeIssues]
67
91
  if tool_calls := message_param.get("tool_calls"):
68
92
  for tool_call in tool_calls:
@@ -1,10 +1,16 @@
1
1
  """Utility for converting `BaseMessageParam` to `Content`"""
2
2
 
3
+ import base64
4
+ import io
5
+
6
+ import PIL.Image
3
7
  from google.cloud.aiplatform_v1beta1.types import content as gapic_content_types
4
8
  from google.cloud.aiplatform_v1beta1.types import tool as gapic_tool_types
5
9
  from vertexai.generative_models import Content, Image, Part
6
10
 
7
11
  from ...base import BaseMessageParam
12
+ from ...base._utils import get_audio_type
13
+ from ...base._utils._parse_content_template import _load_media
8
14
 
9
15
 
10
16
  def convert_message_params(
@@ -22,15 +28,11 @@ def convert_message_params(
22
28
  ) # pragma: no cover
23
29
  converted_message_params += [
24
30
  Content(
25
- role="user",
31
+ role="system",
26
32
  parts=[
27
33
  Part.from_text(content) if isinstance(content, str) else content
28
34
  ],
29
35
  ),
30
- Content(
31
- role="model",
32
- parts=[Part.from_text("Ok! I will adhere to this system message.")],
33
- ),
34
36
  ]
35
37
  elif isinstance((content := message_param.content), str):
36
38
  converted_message_params.append(
@@ -56,6 +58,29 @@ def convert_message_params(
56
58
  )
57
59
  image = Image.from_bytes(part.image)
58
60
  converted_content.append(Part.from_image(image))
61
+ elif part.type == "image_url":
62
+ # Should download the image to determine the media type
63
+ image = PIL.Image.open(io.BytesIO(_load_media(part.url)))
64
+ media_type = (
65
+ PIL.Image.MIME[image.format]
66
+ if image.format
67
+ else "image/unknown"
68
+ )
69
+ if media_type not in [
70
+ "image/jpeg",
71
+ "image/png",
72
+ "image/webp",
73
+ "image/heic",
74
+ "image/heif",
75
+ ]:
76
+ raise ValueError(
77
+ f"Unsupported image media type: {media_type}. "
78
+ "Gemini currently only supports JPEG, PNG, WebP, HEIC, "
79
+ "and HEIF images."
80
+ )
81
+ converted_content.append(
82
+ Part.from_uri(part.url, mime_type=media_type)
83
+ )
59
84
  elif part.type == "audio":
60
85
  if part.media_type not in [
61
86
  "audio/wav",
@@ -71,7 +96,32 @@ def convert_message_params(
71
96
  "and FLAC audio file types."
72
97
  )
73
98
  converted_content.append(
74
- Part.from_data(mime_type=part.media_type, data=part.audio)
99
+ Part.from_data(
100
+ mime_type=part.media_type,
101
+ data=part.audio
102
+ if isinstance(part.audio, bytes)
103
+ else base64.b64decode(part.audio),
104
+ )
105
+ )
106
+ elif part.type == "audio_url":
107
+ # Should download the audio to determine the media type
108
+ audio = _load_media(part.url)
109
+ audio_type = get_audio_type(audio)
110
+ if audio_type not in [
111
+ "audio/wav",
112
+ "audio/mp3",
113
+ "audio/aiff",
114
+ "audio/aac",
115
+ "audio/ogg",
116
+ "audio/flac",
117
+ ]:
118
+ raise ValueError(
119
+ f"Unsupported audio media type: {audio_type}. "
120
+ "Gemini currently only supports WAV, MP3, AIFF, AAC, OGG, "
121
+ "and FLAC audio file types."
122
+ )
123
+ converted_content.append(
124
+ Part.from_uri(part.url, mime_type=audio_type)
75
125
  )
76
126
  elif part.type == "tool_call":
77
127
  if converted_content:
@@ -7,7 +7,7 @@ from mirascope.core.base import DocumentPart, ImagePart, TextPart
7
7
  from mirascope.core.base._utils._base_message_param_converter import (
8
8
  BaseMessageParamConverter,
9
9
  )
10
- from mirascope.core.base.message_param import ToolCallPart, ToolResultPart
10
+ from mirascope.core.base.message_param import ImageURLPart, ToolCallPart, ToolResultPart
11
11
  from mirascope.core.vertex._utils import convert_message_params
12
12
 
13
13
 
@@ -87,10 +87,18 @@ class VertexMessageParamConverter(BaseMessageParamConverter):
87
87
  )
88
88
 
89
89
  elif part.file_data:
90
- # part.file_data.file_uri has Google storage URI like "gs://bucket_name/file_name"
91
- raise ValueError(
92
- f"FileData.file_uri is not support: {part.file_data}. Cannot convert to BaseMessageParam."
93
- )
90
+ if _is_image_mime(part.file_data.mime_type):
91
+ contents.append(
92
+ ImageURLPart(
93
+ type="image_url",
94
+ url=part.file_data.file_uri,
95
+ detail=None,
96
+ )
97
+ )
98
+ else:
99
+ raise ValueError(
100
+ f"FileData.file_uri is not support: {part.file_data}. Cannot convert to BaseMessageParam."
101
+ )
94
102
  elif part.function_call:
95
103
  converted.append(
96
104
  BaseMessageParam(
@@ -108,6 +108,7 @@ def setup_call(
108
108
  call_kwargs = cast(VertexCallKwargs, base_call_kwargs)
109
109
  messages = cast(list[BaseMessageParam | Content], messages)
110
110
  messages = convert_message_params(messages)
111
+
111
112
  if json_mode:
112
113
  generation_config = call_kwargs.get(
113
114
  "generation_config",
@@ -131,11 +132,19 @@ def setup_call(
131
132
  )
132
133
  )
133
134
  call_kwargs["tool_config"] = tool_config
134
- call_kwargs |= {"contents": messages}
135
135
 
136
136
  if client is None:
137
137
  client = GenerativeModel(model_name=model)
138
138
 
139
+ if messages and messages[0].role == "system":
140
+ system_instruction = client._system_instruction
141
+ if not isinstance(system_instruction, list):
142
+ system_instruction = [system_instruction] if system_instruction else []
143
+ system_instruction.extend(messages.pop(0).parts)
144
+ client._system_instruction = system_instruction
145
+
146
+ call_kwargs |= {"contents": messages}
147
+
139
148
  create = (
140
149
  cast(
141
150
  AsyncCreateFn[GenerationResponse, AsyncIterable[GenerationResponse]],
@@ -51,7 +51,11 @@ class CallResponse(
51
51
  response: BaseCallResponse[_ResponseT, _BaseToolT, Any, Any, Any, Any, Any],
52
52
  ) -> None:
53
53
  super().__init__(
54
- **{field: getattr(response, field) for field in response.model_fields}
54
+ **{
55
+ field: getattr(response, field)
56
+ for field in response.model_fields
57
+ if field != "user_message_param"
58
+ }
55
59
  )
56
60
  object.__setattr__(self, "_response", response)
57
61
  object.__setattr__(
@@ -2,5 +2,10 @@
2
2
 
3
3
  from contextlib import suppress
4
4
 
5
+ from .fallback import FallbackError, fallback
6
+
5
7
  with suppress(ImportError):
6
8
  from . import tenacity as tenacity
9
+
10
+
11
+ __all__ = ["fallback", "FallbackError", "tenacity"]