crewplus 0.1.2__py3-none-any.whl → 0.1.3__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.
Potentially problematic release.
This version of crewplus might be problematic. Click here for more details.
- crewplus/services/gemini_chat_model.py +452 -227
- {crewplus-0.1.2.dist-info → crewplus-0.1.3.dist-info}/METADATA +12 -3
- crewplus-0.1.3.dist-info/RECORD +9 -0
- crewplus-0.1.2.dist-info/RECORD +0 -9
- {crewplus-0.1.2.dist-info → crewplus-0.1.3.dist-info}/WHEEL +0 -0
- {crewplus-0.1.2.dist-info → crewplus-0.1.3.dist-info}/entry_points.txt +0 -0
- {crewplus-0.1.2.dist-info → crewplus-0.1.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import asyncio
|
|
3
|
-
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any, Dict, Iterator, List, Optional, AsyncIterator, Union, Tuple
|
|
4
5
|
from google import genai
|
|
6
|
+
from google.genai import types
|
|
7
|
+
import base64
|
|
8
|
+
import requests
|
|
5
9
|
from langchain_core.language_models import BaseChatModel
|
|
6
10
|
from langchain_core.messages import (
|
|
7
11
|
AIMessage,
|
|
@@ -19,35 +23,121 @@ from pydantic import Field, SecretStr
|
|
|
19
23
|
from langchain_core.utils import convert_to_secret_str
|
|
20
24
|
|
|
21
25
|
class GeminiChatModel(BaseChatModel):
|
|
22
|
-
"""Custom chat model
|
|
23
|
-
|
|
24
|
-
This implementation provides direct access to Google's genai features
|
|
25
|
-
while being compatible with LangChain's BaseChatModel interface.
|
|
26
|
+
"""Custom chat model for Google Gemini, supporting text, image, and video.
|
|
26
27
|
|
|
28
|
+
This model provides a robust interface to Google's Gemini Pro and Flash models,
|
|
29
|
+
handling various data formats for multimodal inputs while maintaining compatibility
|
|
30
|
+
with the LangChain ecosystem.
|
|
31
|
+
|
|
32
|
+
It supports standard invocation, streaming, and asynchronous operations.
|
|
33
|
+
API keys can be provided directly or loaded from the `GOOGLE_API_KEY`
|
|
34
|
+
environment variable.
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
model_name (str): The Google model name to use (e.g., "gemini-1.5-flash").
|
|
38
|
+
google_api_key (Optional[SecretStr]): Your Google API key.
|
|
39
|
+
temperature (Optional[float]): The sampling temperature for generation.
|
|
40
|
+
max_tokens (Optional[int]): The maximum number of tokens to generate.
|
|
41
|
+
top_p (Optional[float]): The top-p (nucleus) sampling parameter.
|
|
42
|
+
top_k (Optional[int]): The top-k sampling parameter.
|
|
43
|
+
logger (Optional[logging.Logger]): An optional logger instance.
|
|
44
|
+
|
|
27
45
|
Example:
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
print(
|
|
46
|
+
.. code-block:: python
|
|
47
|
+
|
|
48
|
+
from crewplus.services import GeminiChatModel
|
|
49
|
+
from langchain_core.messages import HumanMessage
|
|
50
|
+
import base64
|
|
51
|
+
import logging
|
|
52
|
+
|
|
53
|
+
# Initialize the model with optional logger
|
|
54
|
+
logger = logging.getLogger("my_app.gemini")
|
|
55
|
+
model = GeminiChatModel(model_name="gemini-2.0-flash", logger=logger)
|
|
56
|
+
|
|
57
|
+
# --- Text-only usage ---
|
|
58
|
+
response = model.invoke("Hello, how are you?")
|
|
59
|
+
print("Text response:", response.content)
|
|
60
|
+
|
|
61
|
+
# --- Image processing with base64 data URI ---
|
|
62
|
+
# Replace with a path to your image
|
|
63
|
+
image_path = "path/to/your/image.jpg"
|
|
64
|
+
try:
|
|
65
|
+
with open(image_path, "rb") as image_file:
|
|
66
|
+
encoded_string = base64.b64encode(image_file.read()).decode('utf-8')
|
|
67
|
+
|
|
68
|
+
image_message = HumanMessage(
|
|
69
|
+
content=[
|
|
70
|
+
{"type": "text", "text": "What is in this image?"},
|
|
71
|
+
{
|
|
72
|
+
"type": "image_url",
|
|
73
|
+
"image_url": {
|
|
74
|
+
"url": f"data:image/jpeg;base64,{encoded_string}"
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
]
|
|
78
|
+
)
|
|
79
|
+
image_response = model.invoke([image_message])
|
|
80
|
+
print("Image response (base64):", image_response.content)
|
|
81
|
+
except FileNotFoundError:
|
|
82
|
+
print(f"Image file not found at {image_path}, skipping base64 example.")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# --- Image processing with URL ---
|
|
86
|
+
url_message = HumanMessage(
|
|
87
|
+
content=[
|
|
88
|
+
{"type": "text", "text": "Describe this image:"},
|
|
89
|
+
{
|
|
90
|
+
"type": "image_url",
|
|
91
|
+
"image_url": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg"
|
|
92
|
+
},
|
|
93
|
+
]
|
|
94
|
+
)
|
|
95
|
+
url_response = model.invoke([url_message])
|
|
96
|
+
print("Image response (URL):", url_response.content)
|
|
42
97
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
98
|
+
# --- Video processing with file path (>=20MB) ---
|
|
99
|
+
video_path = "path/to/your/video.mp4"
|
|
100
|
+
video_file = client.files.upload(file=video_path)
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
video_message = HumanMessage(
|
|
104
|
+
content=[
|
|
105
|
+
{"type": "text", "text": "Summarize this video."},
|
|
106
|
+
{"type": "video_file", "file": video_file},
|
|
107
|
+
]
|
|
108
|
+
)
|
|
109
|
+
video_response = model.invoke([video_message])
|
|
110
|
+
print("Video response (file path):", video_response.content)
|
|
111
|
+
except Exception as e:
|
|
112
|
+
print(f"Video processing with file path failed: {e}")
|
|
113
|
+
|
|
114
|
+
# --- Video processing with raw bytes (<20MB) ---
|
|
115
|
+
video_path = "path/to/your/video.mp4"
|
|
116
|
+
try:
|
|
117
|
+
with open(video_path, "rb") as video_file:
|
|
118
|
+
video_bytes = video_file.read()
|
|
119
|
+
|
|
120
|
+
video_message = HumanMessage(
|
|
121
|
+
content=[
|
|
122
|
+
{"type": "text", "text": "What is happening in this video?"},
|
|
123
|
+
{
|
|
124
|
+
"type": "video_file",
|
|
125
|
+
"data": video_bytes,
|
|
126
|
+
"mime_type": "video/mp4"
|
|
127
|
+
},
|
|
128
|
+
]
|
|
129
|
+
)
|
|
130
|
+
video_response = model.invoke([video_message])
|
|
131
|
+
print("Video response (bytes):", video_response.content)
|
|
132
|
+
except FileNotFoundError:
|
|
133
|
+
print(f"Video file not found at {video_path}, skipping bytes example.")
|
|
134
|
+
except Exception as e:
|
|
135
|
+
print(f"Video processing with bytes failed: {e}")
|
|
47
136
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
137
|
+
# --- Streaming usage (works with text, images, and video) ---
|
|
138
|
+
print("Streaming response:")
|
|
139
|
+
for chunk in model.stream([url_message]):
|
|
140
|
+
print(chunk.content, end="", flush=True)
|
|
51
141
|
"""
|
|
52
142
|
|
|
53
143
|
# Model configuration
|
|
@@ -57,6 +147,7 @@ class GeminiChatModel(BaseChatModel):
|
|
|
57
147
|
max_tokens: Optional[int] = Field(default=None, description="Maximum tokens to generate")
|
|
58
148
|
top_p: Optional[float] = Field(default=None, description="Top-p sampling parameter")
|
|
59
149
|
top_k: Optional[int] = Field(default=None, description="Top-k sampling parameter")
|
|
150
|
+
logger: Optional[logging.Logger] = Field(default=None, description="Optional logger instance")
|
|
60
151
|
|
|
61
152
|
# Internal client
|
|
62
153
|
_client: Optional[genai.Client] = None
|
|
@@ -64,6 +155,13 @@ class GeminiChatModel(BaseChatModel):
|
|
|
64
155
|
def __init__(self, **kwargs):
|
|
65
156
|
super().__init__(**kwargs)
|
|
66
157
|
|
|
158
|
+
# Initialize logger
|
|
159
|
+
if self.logger is None:
|
|
160
|
+
self.logger = logging.getLogger(f"{self.__class__.__module__}.{self.__class__.__name__}")
|
|
161
|
+
if not self.logger.handlers: # and not getattr(self.logger, 'propagate', True):
|
|
162
|
+
self.logger.addHandler(logging.StreamHandler())
|
|
163
|
+
self.logger.setLevel(logging.INFO)
|
|
164
|
+
|
|
67
165
|
# Get API key from environment if not provided
|
|
68
166
|
if self.google_api_key is None:
|
|
69
167
|
api_key = os.getenv("GOOGLE_API_KEY")
|
|
@@ -75,8 +173,11 @@ class GeminiChatModel(BaseChatModel):
|
|
|
75
173
|
self._client = genai.Client(
|
|
76
174
|
api_key=self.google_api_key.get_secret_value()
|
|
77
175
|
)
|
|
176
|
+
self.logger.info(f"Initialized GeminiChatModel with model: {self.model_name}")
|
|
78
177
|
else:
|
|
79
|
-
|
|
178
|
+
error_msg = "Google API key is required. Set GOOGLE_API_KEY environment variable or pass google_api_key parameter."
|
|
179
|
+
self.logger.error(error_msg)
|
|
180
|
+
raise ValueError(error_msg)
|
|
80
181
|
|
|
81
182
|
@property
|
|
82
183
|
def _llm_type(self) -> str:
|
|
@@ -94,49 +195,272 @@ class GeminiChatModel(BaseChatModel):
|
|
|
94
195
|
"top_k": self.top_k,
|
|
95
196
|
}
|
|
96
197
|
|
|
97
|
-
def
|
|
98
|
-
"""
|
|
198
|
+
def _convert_messages(self, messages: List[BaseMessage]) -> Union[types.ContentListUnion, types.ContentListUnionDict]:
|
|
199
|
+
"""
|
|
200
|
+
Converts LangChain messages to a format suitable for the GenAI API.
|
|
201
|
+
- For single, multi-part HumanMessage, returns a direct list of parts (e.g., [File, "text"]).
|
|
202
|
+
- For multi-turn chats, returns a list of Content objects.
|
|
203
|
+
- For simple text, returns a string.
|
|
204
|
+
"""
|
|
205
|
+
self.logger.debug(f"Converting {len(messages)} messages.")
|
|
206
|
+
|
|
207
|
+
# Filter out system messages (handled in generation_config)
|
|
208
|
+
chat_messages = [msg for msg in messages if not isinstance(msg, SystemMessage)]
|
|
209
|
+
|
|
210
|
+
# Case 1: A single HumanMessage. This is the most common path for single prompts.
|
|
211
|
+
if len(chat_messages) == 1 and isinstance(chat_messages[0], HumanMessage):
|
|
212
|
+
content = chat_messages[0].content
|
|
213
|
+
# For a simple string, return it directly.
|
|
214
|
+
if isinstance(content, str):
|
|
215
|
+
return content
|
|
216
|
+
# For a list of parts, parse them into a direct list for the API.
|
|
217
|
+
return list(self._parse_message_content(content, is_simple=True))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# Case 2: Multi-turn chat history. This requires a list of Content objects.
|
|
221
|
+
self.logger.debug("Handling as a multi-turn chat conversation.")
|
|
222
|
+
genai_contents: List[types.Content] = []
|
|
223
|
+
for msg in chat_messages:
|
|
224
|
+
role = "model" if isinstance(msg, AIMessage) else "user"
|
|
225
|
+
parts = []
|
|
226
|
+
|
|
227
|
+
# Process each part and ensure proper typing
|
|
228
|
+
for part in self._parse_message_content(msg.content, is_simple=False):
|
|
229
|
+
if isinstance(part, types.File):
|
|
230
|
+
# put File directly into types.Content
|
|
231
|
+
parts.append(part)
|
|
232
|
+
elif isinstance(part, types.Part):
|
|
233
|
+
parts.append(part)
|
|
234
|
+
else:
|
|
235
|
+
self.logger.warning(f"Unexpected part type: {type(part)}")
|
|
236
|
+
|
|
237
|
+
if parts:
|
|
238
|
+
genai_contents.append(types.Content(parts=parts, role=role))
|
|
99
239
|
|
|
100
|
-
|
|
101
|
-
|
|
240
|
+
# If there's only one Content object, return it directly instead of a list
|
|
241
|
+
if len(genai_contents) == 1:
|
|
242
|
+
return genai_contents[0]
|
|
243
|
+
|
|
244
|
+
return genai_contents
|
|
245
|
+
|
|
246
|
+
def _create_image_part(self, image_info: Dict[str, Any]) -> Union[types.Part, types.File]:
|
|
247
|
+
"""Creates a GenAI Part or File from various image source formats."""
|
|
248
|
+
self.logger.debug(f"Creating image part from info: {list(image_info.keys())}")
|
|
249
|
+
|
|
250
|
+
if "path" in image_info:
|
|
251
|
+
return self._client.files.upload(file=image_info["path"])
|
|
252
|
+
|
|
253
|
+
if "data" in image_info:
|
|
254
|
+
data = image_info["data"]
|
|
255
|
+
if image_info.get("source_type") == "base64":
|
|
256
|
+
data = base64.b64decode(data)
|
|
257
|
+
return types.Part.from_bytes(data=data, mime_type=image_info["mime_type"])
|
|
258
|
+
|
|
259
|
+
url = image_info.get("image_url", image_info.get("url"))
|
|
260
|
+
if isinstance(url, dict):
|
|
261
|
+
url = url.get("url")
|
|
262
|
+
|
|
263
|
+
if not url:
|
|
264
|
+
raise ValueError(f"Invalid image info, requires 'path', 'data', or 'url'. Received: {image_info}")
|
|
265
|
+
|
|
266
|
+
if url.startswith("data:"):
|
|
267
|
+
header, encoded = url.split(",", 1)
|
|
268
|
+
mime_type = header.split(":", 1)[-1].split(";", 1)[0]
|
|
269
|
+
image_data = base64.b64decode(encoded)
|
|
270
|
+
return types.Part.from_bytes(data=image_data, mime_type=mime_type)
|
|
271
|
+
else:
|
|
272
|
+
response = requests.get(url)
|
|
273
|
+
response.raise_for_status()
|
|
274
|
+
mime_type = response.headers.get("Content-Type", "image/jpeg")
|
|
275
|
+
return types.Part.from_bytes(data=response.content, mime_type=mime_type)
|
|
276
|
+
|
|
277
|
+
def _create_video_part(self, video_info: Dict[str, Any]) -> Union[types.Part, types.File]:
|
|
278
|
+
"""Creates a Google GenAI Part or File from video information.
|
|
279
|
+
|
|
280
|
+
Supports multiple video input formats:
|
|
281
|
+
- File object: {"type": "video_file", "file": file_object}
|
|
282
|
+
- File path: {"type": "video_file", "path": "/path/to/video.mp4"}
|
|
283
|
+
- Raw bytes: {"type": "video_file", "data": video_bytes, "mime_type": "video/mp4"}
|
|
284
|
+
- URL/URI: {"type": "video_file", "url": "https://example.com/video.mp4"}
|
|
285
|
+
- YouTube URL: {"type": "video_file", "url": "https://www.youtube.com/watch?v=..."}
|
|
286
|
+
- URL with offset: {"type": "video_file", "url": "...", "start_offset": "12s", "end_offset": "50s"}
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
video_info: Dictionary containing video information
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
Either a types.Part or File object for Google GenAI
|
|
293
|
+
|
|
294
|
+
Raises:
|
|
295
|
+
FileNotFoundError: If video file path doesn't exist
|
|
296
|
+
ValueError: If video_info is invalid or missing required fields
|
|
102
297
|
"""
|
|
103
|
-
|
|
298
|
+
self.logger.debug(f"Creating video part from info: {list(video_info.keys())}")
|
|
104
299
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
elif isinstance(message, HumanMessage):
|
|
110
|
-
prompt_parts.append(f"Human: {message.content}")
|
|
111
|
-
elif isinstance(message, AIMessage):
|
|
112
|
-
prompt_parts.append(f"Assistant: {message.content}")
|
|
300
|
+
# Handle pre-uploaded file object
|
|
301
|
+
if "file" in video_info:
|
|
302
|
+
if isinstance(video_info["file"], types.File):
|
|
303
|
+
return video_info["file"]
|
|
113
304
|
else:
|
|
114
|
-
|
|
115
|
-
|
|
305
|
+
raise ValueError(f"The 'file' key must contain a google.genai.File object, but got {type(video_info['file'])}")
|
|
306
|
+
|
|
307
|
+
if "path" in video_info:
|
|
308
|
+
self.logger.debug(f"Uploading video file from path: {video_info['path']}")
|
|
309
|
+
|
|
310
|
+
uploaded_file =self._client.files.upload(file=video_info["path"])
|
|
311
|
+
|
|
312
|
+
self.logger.debug(f"Uploaded video file: {uploaded_file}")
|
|
313
|
+
|
|
314
|
+
return uploaded_file
|
|
116
315
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
316
|
+
mime_type = video_info.get("mime_type")
|
|
317
|
+
|
|
318
|
+
if "data" in video_info:
|
|
319
|
+
data = video_info["data"]
|
|
320
|
+
if not mime_type:
|
|
321
|
+
raise ValueError("'mime_type' is required when providing video data.")
|
|
322
|
+
max_size = 20 * 1024 * 1024 # 20MB
|
|
323
|
+
if len(data) > max_size:
|
|
324
|
+
raise ValueError(f"Video data size ({len(data)} bytes) exceeds 20MB limit for inline data.")
|
|
325
|
+
return types.Part(inline_data=types.Blob(data=data, mime_type=mime_type))
|
|
326
|
+
|
|
327
|
+
url = video_info.get("url")
|
|
328
|
+
if not url:
|
|
329
|
+
raise ValueError(f"Invalid video info, requires 'path', 'data', 'url', or 'file'. Received: {video_info}")
|
|
330
|
+
|
|
331
|
+
mime_type = video_info.get("mime_type", "video/mp4")
|
|
120
332
|
|
|
121
|
-
|
|
333
|
+
# Handle video offsets
|
|
334
|
+
start_offset = video_info.get("start_offset")
|
|
335
|
+
end_offset = video_info.get("end_offset")
|
|
336
|
+
|
|
337
|
+
self.logger.debug(f"Video offsets: {start_offset} to {end_offset}.")
|
|
122
338
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
339
|
+
if start_offset or end_offset:
|
|
340
|
+
video_metadata = types.VideoMetadata(start_offset=start_offset, end_offset=end_offset)
|
|
341
|
+
return types.Part(
|
|
342
|
+
file_data=types.FileData(file_uri=url, mime_type=mime_type),
|
|
343
|
+
video_metadata=video_metadata
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
return types.Part(file_data=types.FileData(file_uri=url, mime_type=mime_type))
|
|
347
|
+
|
|
348
|
+
def _parse_message_content(
|
|
349
|
+
self, content: Union[str, List[Union[str, Dict]]], *, is_simple: bool = True
|
|
350
|
+
) -> Iterator[Union[str, types.Part, types.File]]:
|
|
351
|
+
"""
|
|
352
|
+
Parses LangChain message content and yields parts for Google GenAI.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
content: The message content to parse.
|
|
356
|
+
is_simple: If True, yields raw objects where possible (e.g., str, File)
|
|
357
|
+
for single-turn efficiency. If False, ensures all yielded
|
|
358
|
+
parts are `types.Part` by converting raw strings and
|
|
359
|
+
Files as needed, which is required for multi-turn chat.
|
|
360
|
+
|
|
361
|
+
Supports both standard LangChain formats and enhanced video formats:
|
|
362
|
+
- Text: "string" or {"type": "text", "text": "content"}
|
|
363
|
+
- Image: {"type": "image_url", "image_url": "url"} or {"type": "image_url", "image_url": {"url": "url"}}
|
|
364
|
+
- Video: {"type": "video_file", ...} or {"type": "video", ...}
|
|
365
|
+
"""
|
|
366
|
+
if isinstance(content, str):
|
|
367
|
+
yield content if is_simple else types.Part(text=content)
|
|
368
|
+
return
|
|
369
|
+
|
|
370
|
+
if not isinstance(content, list):
|
|
371
|
+
self.logger.warning(f"Unsupported content format: {type(content)}")
|
|
372
|
+
return
|
|
373
|
+
|
|
374
|
+
for i, part_spec in enumerate(content):
|
|
375
|
+
try:
|
|
376
|
+
if isinstance(part_spec, str):
|
|
377
|
+
yield part_spec if is_simple else types.Part(text=part_spec)
|
|
378
|
+
continue
|
|
379
|
+
|
|
380
|
+
if isinstance(part_spec, types.File):
|
|
381
|
+
if is_simple:
|
|
382
|
+
yield part_spec
|
|
383
|
+
else:
|
|
384
|
+
yield types.Part(file_data=types.FileData(
|
|
385
|
+
mime_type=part_spec.mime_type,
|
|
386
|
+
file_uri=part_spec.uri
|
|
387
|
+
))
|
|
388
|
+
continue
|
|
389
|
+
|
|
390
|
+
if not isinstance(part_spec, dict):
|
|
391
|
+
self.logger.warning(f"Skipping non-dict part in content list: {type(part_spec)}")
|
|
392
|
+
continue
|
|
393
|
+
|
|
394
|
+
part_type = part_spec.get("type", "").lower()
|
|
395
|
+
|
|
396
|
+
if part_type == "text":
|
|
397
|
+
if text_content := part_spec.get("text"):
|
|
398
|
+
yield text_content if is_simple else types.Part(text=text_content)
|
|
399
|
+
elif part_type in ("image", "image_url"):
|
|
400
|
+
yield self._create_image_part(part_spec)
|
|
401
|
+
elif part_type in ("video", "video_file"):
|
|
402
|
+
yield self._create_video_part(part_spec)
|
|
403
|
+
else:
|
|
404
|
+
self.logger.debug(f"Part with unknown type '{part_type}' was ignored at index {i}.")
|
|
405
|
+
except Exception as e:
|
|
406
|
+
self.logger.error(f"Failed to process message part at index {i}: {part_spec}. Error: {e}", exc_info=True)
|
|
407
|
+
|
|
408
|
+
def _prepare_generation_config(
|
|
409
|
+
self, messages: List[BaseMessage], stop: Optional[List[str]] = None
|
|
410
|
+
) -> Dict[str, Any]:
|
|
411
|
+
"""Prepares the generation configuration, including system instructions."""
|
|
412
|
+
# Base config from model parameters
|
|
413
|
+
config = {
|
|
414
|
+
"temperature": self.temperature,
|
|
415
|
+
"max_output_tokens": self.max_tokens,
|
|
416
|
+
"top_p": self.top_p,
|
|
417
|
+
"top_k": self.top_k,
|
|
418
|
+
}
|
|
136
419
|
if stop:
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
420
|
+
config["stop_sequences"] = stop
|
|
421
|
+
|
|
422
|
+
# Handle system instructions
|
|
423
|
+
system_prompts = [msg.content for msg in messages if isinstance(msg, SystemMessage) and msg.content]
|
|
424
|
+
if system_prompts:
|
|
425
|
+
system_prompt_str = "\n\n".join(system_prompts)
|
|
426
|
+
config["system_instruction"] = system_prompt_str
|
|
427
|
+
|
|
428
|
+
# Filter out None values before returning
|
|
429
|
+
return {k: v for k, v in config.items() if v is not None}
|
|
430
|
+
|
|
431
|
+
def _trim_for_logging(self, contents: Any) -> Any:
|
|
432
|
+
"""Helper to trim large binary data from logging payloads."""
|
|
433
|
+
if isinstance(contents, str):
|
|
434
|
+
return contents
|
|
435
|
+
|
|
436
|
+
if isinstance(contents, types.Content):
|
|
437
|
+
return {
|
|
438
|
+
"role": contents.role,
|
|
439
|
+
"parts": [self._trim_part(part) for part in contents.parts]
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if isinstance(contents, list):
|
|
443
|
+
return [self._trim_for_logging(item) for item in contents]
|
|
444
|
+
|
|
445
|
+
return contents
|
|
446
|
+
|
|
447
|
+
def _trim_part(self, part: types.Part) -> dict:
|
|
448
|
+
"""Trims individual part data for safe logging."""
|
|
449
|
+
part_dict = {}
|
|
450
|
+
if part.text:
|
|
451
|
+
part_dict["text"] = part.text
|
|
452
|
+
if part.inline_data:
|
|
453
|
+
part_dict["inline_data"] = {
|
|
454
|
+
"mime_type": part.inline_data.mime_type,
|
|
455
|
+
"data_size": f"{len(part.inline_data.data)} bytes"
|
|
456
|
+
}
|
|
457
|
+
if part.file_data:
|
|
458
|
+
part_dict["file_data"] = {
|
|
459
|
+
"mime_type": part.file_data.mime_type,
|
|
460
|
+
"file_uri": part.file_data.file_uri
|
|
461
|
+
}
|
|
462
|
+
return part_dict
|
|
463
|
+
|
|
140
464
|
def _generate(
|
|
141
465
|
self,
|
|
142
466
|
messages: List[BaseMessage],
|
|
@@ -144,41 +468,33 @@ class GeminiChatModel(BaseChatModel):
|
|
|
144
468
|
run_manager: Optional[CallbackManagerForLLMRun] = None,
|
|
145
469
|
**kwargs: Any,
|
|
146
470
|
) -> ChatResult:
|
|
147
|
-
"""
|
|
148
|
-
|
|
149
|
-
# Convert messages to a single prompt string
|
|
150
|
-
prompt = self._convert_messages_to_genai_format(messages)
|
|
151
|
-
|
|
152
|
-
# Prepare generation config
|
|
153
|
-
generation_config = self._prepare_generation_config(stop)
|
|
471
|
+
"""Generates a chat response from a list of messages."""
|
|
472
|
+
self.logger.info(f"Generating response for {len(messages)} messages.")
|
|
154
473
|
|
|
474
|
+
contents = self._convert_messages(messages)
|
|
475
|
+
config = self._prepare_generation_config(messages, stop)
|
|
476
|
+
|
|
155
477
|
try:
|
|
156
|
-
# Generate response using Google GenAI
|
|
157
478
|
response = self._client.models.generate_content(
|
|
158
479
|
model=self.model_name,
|
|
159
|
-
contents=
|
|
160
|
-
config=
|
|
480
|
+
contents=contents,
|
|
481
|
+
config=config,
|
|
482
|
+
**kwargs,
|
|
161
483
|
)
|
|
162
484
|
|
|
163
|
-
|
|
164
|
-
|
|
485
|
+
generated_text = response.text
|
|
486
|
+
finish_reason = response.candidates[0].finish_reason.name if response.candidates else None
|
|
165
487
|
|
|
166
|
-
# Create AI message with response metadata
|
|
167
488
|
message = AIMessage(
|
|
168
489
|
content=generated_text,
|
|
169
|
-
response_metadata={
|
|
170
|
-
"model_name": self.model_name,
|
|
171
|
-
"finish_reason": getattr(response, 'finish_reason', None),
|
|
172
|
-
}
|
|
490
|
+
response_metadata={"model_name": self.model_name, "finish_reason": finish_reason},
|
|
173
491
|
)
|
|
174
|
-
|
|
175
|
-
# Create and return ChatResult
|
|
176
|
-
generation = ChatGeneration(message=message)
|
|
177
|
-
return ChatResult(generations=[generation])
|
|
492
|
+
return ChatResult(generations=[ChatGeneration(message=message)])
|
|
178
493
|
|
|
179
494
|
except Exception as e:
|
|
180
|
-
|
|
181
|
-
|
|
495
|
+
self.logger.error(f"Error generating content with Google GenAI: {e}", exc_info=True)
|
|
496
|
+
raise ValueError(f"Error during generation: {e}")
|
|
497
|
+
|
|
182
498
|
async def _agenerate(
|
|
183
499
|
self,
|
|
184
500
|
messages: List[BaseMessage],
|
|
@@ -186,45 +502,33 @@ class GeminiChatModel(BaseChatModel):
|
|
|
186
502
|
run_manager: Optional[AsyncCallbackManagerForLLMRun] = None,
|
|
187
503
|
**kwargs: Any,
|
|
188
504
|
) -> ChatResult:
|
|
189
|
-
"""
|
|
190
|
-
|
|
191
|
-
# Convert messages to a single prompt string
|
|
192
|
-
prompt = self._convert_messages_to_genai_format(messages)
|
|
193
|
-
|
|
194
|
-
# Prepare generation config
|
|
195
|
-
generation_config = self._prepare_generation_config(stop)
|
|
505
|
+
"""Asynchronously generates a chat response."""
|
|
506
|
+
self.logger.info(f"Async generating response for {len(messages)} messages.")
|
|
196
507
|
|
|
508
|
+
contents = self._convert_messages(messages)
|
|
509
|
+
config = self._prepare_generation_config(messages, stop)
|
|
510
|
+
|
|
197
511
|
try:
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
model=self.model_name,
|
|
204
|
-
contents=prompt,
|
|
205
|
-
config=generation_config if generation_config else None
|
|
206
|
-
)
|
|
512
|
+
response = await self._client.generate_content(
|
|
513
|
+
model=self.model_name,
|
|
514
|
+
contents=contents,
|
|
515
|
+
config=config,
|
|
516
|
+
**kwargs,
|
|
207
517
|
)
|
|
208
518
|
|
|
209
|
-
|
|
210
|
-
|
|
519
|
+
generated_text = response.text
|
|
520
|
+
finish_reason = response.candidates[0].finish_reason.name if response.candidates else None
|
|
211
521
|
|
|
212
|
-
# Create AI message with response metadata
|
|
213
522
|
message = AIMessage(
|
|
214
523
|
content=generated_text,
|
|
215
|
-
response_metadata={
|
|
216
|
-
"model_name": self.model_name,
|
|
217
|
-
"finish_reason": getattr(response, 'finish_reason', None),
|
|
218
|
-
}
|
|
524
|
+
response_metadata={"model_name": self.model_name, "finish_reason": finish_reason},
|
|
219
525
|
)
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
generation = ChatGeneration(message=message)
|
|
223
|
-
return ChatResult(generations=[generation])
|
|
224
|
-
|
|
526
|
+
return ChatResult(generations=[ChatGeneration(message=message)])
|
|
527
|
+
|
|
225
528
|
except Exception as e:
|
|
226
|
-
|
|
227
|
-
|
|
529
|
+
self.logger.error(f"Error during async generation: {e}", exc_info=True)
|
|
530
|
+
raise ValueError(f"Error during async generation: {e}")
|
|
531
|
+
|
|
228
532
|
def _stream(
|
|
229
533
|
self,
|
|
230
534
|
messages: List[BaseMessage],
|
|
@@ -232,68 +536,29 @@ class GeminiChatModel(BaseChatModel):
|
|
|
232
536
|
run_manager: Optional[CallbackManagerForLLMRun] = None,
|
|
233
537
|
**kwargs: Any,
|
|
234
538
|
) -> Iterator[ChatGenerationChunk]:
|
|
235
|
-
"""
|
|
236
|
-
|
|
237
|
-
# Convert messages to a single prompt string
|
|
238
|
-
prompt = self._convert_messages_to_genai_format(messages)
|
|
239
|
-
|
|
240
|
-
# Prepare generation config
|
|
241
|
-
generation_config = self._prepare_generation_config(stop)
|
|
539
|
+
"""Streams the chat response."""
|
|
540
|
+
self.logger.info(f"Streaming response for {len(messages)} messages.")
|
|
242
541
|
|
|
542
|
+
contents = self._convert_messages(messages)
|
|
543
|
+
config = self._prepare_generation_config(messages, stop)
|
|
544
|
+
|
|
243
545
|
try:
|
|
244
|
-
# Use Google GenAI streaming
|
|
245
546
|
stream = self._client.models.generate_content_stream(
|
|
246
547
|
model=self.model_name,
|
|
247
|
-
contents=
|
|
248
|
-
config=
|
|
548
|
+
contents=contents,
|
|
549
|
+
config=config,
|
|
550
|
+
**kwargs,
|
|
249
551
|
)
|
|
250
|
-
|
|
251
552
|
for chunk_response in stream:
|
|
252
|
-
if
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
chunk = ChatGenerationChunk(
|
|
256
|
-
message=AIMessageChunk(
|
|
257
|
-
content=content,
|
|
258
|
-
response_metadata={
|
|
259
|
-
"model_name": self.model_name,
|
|
260
|
-
"finish_reason": getattr(chunk_response, 'finish_reason', None),
|
|
261
|
-
}
|
|
262
|
-
)
|
|
263
|
-
)
|
|
264
|
-
yield chunk
|
|
265
|
-
|
|
266
|
-
# Trigger callback for new token
|
|
553
|
+
if text_content := chunk_response.text:
|
|
554
|
+
chunk = ChatGenerationChunk(message=AIMessageChunk(content=text_content))
|
|
267
555
|
if run_manager:
|
|
268
|
-
run_manager.on_llm_new_token(
|
|
269
|
-
|
|
270
|
-
except Exception as e:
|
|
271
|
-
# Fallback to non-streaming if streaming fails
|
|
272
|
-
try:
|
|
273
|
-
response = self._client.models.generate_content(
|
|
274
|
-
model=self.model_name,
|
|
275
|
-
contents=prompt,
|
|
276
|
-
config=generation_config if generation_config else None
|
|
277
|
-
)
|
|
278
|
-
|
|
279
|
-
generated_text = response.text if hasattr(response, 'text') else str(response)
|
|
280
|
-
|
|
281
|
-
# Simulate streaming by yielding words
|
|
282
|
-
words = generated_text.split()
|
|
283
|
-
for i, word in enumerate(words):
|
|
284
|
-
content = f" {word}" if i > 0 else word
|
|
285
|
-
|
|
286
|
-
chunk = ChatGenerationChunk(
|
|
287
|
-
message=AIMessageChunk(content=content)
|
|
288
|
-
)
|
|
556
|
+
run_manager.on_llm_new_token(text_content, chunk=chunk)
|
|
289
557
|
yield chunk
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
except Exception as fallback_e:
|
|
295
|
-
raise ValueError(f"Error streaming content with Google GenAI: {str(e)}. Fallback also failed: {str(fallback_e)}")
|
|
296
|
-
|
|
558
|
+
except Exception as e:
|
|
559
|
+
self.logger.error(f"Error streaming content: {e}", exc_info=True)
|
|
560
|
+
raise ValueError(f"Error during streaming: {e}")
|
|
561
|
+
|
|
297
562
|
async def _astream(
|
|
298
563
|
self,
|
|
299
564
|
messages: List[BaseMessage],
|
|
@@ -301,65 +566,25 @@ class GeminiChatModel(BaseChatModel):
|
|
|
301
566
|
run_manager: Optional[AsyncCallbackManagerForLLMRun] = None,
|
|
302
567
|
**kwargs: Any,
|
|
303
568
|
) -> AsyncIterator[ChatGenerationChunk]:
|
|
304
|
-
"""
|
|
305
|
-
|
|
306
|
-
# Convert messages to a single prompt string
|
|
307
|
-
prompt = self._convert_messages_to_genai_format(messages)
|
|
308
|
-
|
|
309
|
-
# Prepare generation config
|
|
310
|
-
generation_config = self._prepare_generation_config(stop)
|
|
569
|
+
"""Asynchronously streams the chat response."""
|
|
570
|
+
self.logger.info(f"Async streaming response for {len(messages)} messages.")
|
|
311
571
|
|
|
572
|
+
contents = self._convert_messages(messages)
|
|
573
|
+
config = self._prepare_generation_config(messages, stop)
|
|
574
|
+
|
|
312
575
|
try:
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
)
|
|
323
|
-
|
|
324
|
-
stream = await loop.run_in_executor(None, create_stream)
|
|
325
|
-
|
|
326
|
-
for chunk_response in stream:
|
|
327
|
-
if hasattr(chunk_response, 'text') and chunk_response.text:
|
|
328
|
-
content = chunk_response.text
|
|
329
|
-
|
|
330
|
-
chunk = ChatGenerationChunk(
|
|
331
|
-
message=AIMessageChunk(
|
|
332
|
-
content=content,
|
|
333
|
-
response_metadata={
|
|
334
|
-
"model_name": self.model_name,
|
|
335
|
-
"finish_reason": getattr(chunk_response, 'finish_reason', None),
|
|
336
|
-
}
|
|
337
|
-
)
|
|
338
|
-
)
|
|
339
|
-
yield chunk
|
|
340
|
-
|
|
341
|
-
# Trigger callback for new token
|
|
576
|
+
stream = await self._client.generate_content_async(
|
|
577
|
+
model=self.model_name,
|
|
578
|
+
contents=contents,
|
|
579
|
+
config=config,
|
|
580
|
+
**kwargs,
|
|
581
|
+
)
|
|
582
|
+
async for chunk_response in stream:
|
|
583
|
+
if text_content := chunk_response.text:
|
|
584
|
+
chunk = ChatGenerationChunk(message=AIMessageChunk(content=text_content))
|
|
342
585
|
if run_manager:
|
|
343
|
-
await run_manager.on_llm_new_token(
|
|
344
|
-
|
|
345
|
-
except Exception as e:
|
|
346
|
-
# Fallback to async generate and simulate streaming
|
|
347
|
-
try:
|
|
348
|
-
result = await self._agenerate(messages, stop, run_manager, **kwargs)
|
|
349
|
-
generated_text = result.generations[0].message.content
|
|
350
|
-
|
|
351
|
-
# Simulate streaming by yielding words
|
|
352
|
-
words = generated_text.split()
|
|
353
|
-
for i, word in enumerate(words):
|
|
354
|
-
content = f" {word}" if i > 0 else word
|
|
355
|
-
|
|
356
|
-
chunk = ChatGenerationChunk(
|
|
357
|
-
message=AIMessageChunk(content=content)
|
|
358
|
-
)
|
|
586
|
+
await run_manager.on_llm_new_token(text_content, chunk=chunk)
|
|
359
587
|
yield chunk
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
except Exception as fallback_e:
|
|
365
|
-
raise ValueError(f"Error async streaming content with Google GenAI: {str(e)}. Fallback also failed: {str(fallback_e)}")
|
|
588
|
+
except Exception as e:
|
|
589
|
+
self.logger.error(f"Error during async streaming: {e}", exc_info=True)
|
|
590
|
+
raise ValueError(f"Error during async streaming: {e}")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: crewplus
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Base services for CrewPlus AI applications
|
|
5
5
|
Author-Email: Tim Liu <tim@opsmateai.com>
|
|
6
6
|
License: MIT
|
|
@@ -12,6 +12,9 @@ Requires-Python: <4.0,>=3.11
|
|
|
12
12
|
Requires-Dist: langchain==0.3.25
|
|
13
13
|
Requires-Dist: langchain-openai==0.3.24
|
|
14
14
|
Requires-Dist: google-genai==1.21.1
|
|
15
|
+
Requires-Dist: mkdocs<2.0.0,>=1.6.1
|
|
16
|
+
Requires-Dist: mkdocs-material<10.0.0,>=9.6.14
|
|
17
|
+
Requires-Dist: mkdocstrings-python<2.0.0,>=1.16.12
|
|
15
18
|
Description-Content-Type: text/markdown
|
|
16
19
|
|
|
17
20
|
# CrewPlus
|
|
@@ -44,6 +47,12 @@ CrewPlus is designed as a modular and extensible ecosystem of packages. This all
|
|
|
44
47
|
- **Vector DB Services:** Abstractions for working with popular vector stores for retrieval-augmented generation (RAG).
|
|
45
48
|
- **Centralized Configuration:** Manage application settings and secrets from a single source of truth (`core/config.py`).
|
|
46
49
|
|
|
50
|
+
## Documentation
|
|
51
|
+
|
|
52
|
+
For detailed guides and API references, please see the `docs/` folder.
|
|
53
|
+
|
|
54
|
+
- **[GeminiChatModel Documentation](./docs/GeminiChatModel.md)**: A comprehensive guide to using the `GeminiChatModel` for text, image, and video understanding.
|
|
55
|
+
|
|
47
56
|
## Installation
|
|
48
57
|
|
|
49
58
|
To install the core `crewplus` package, run the following command:
|
|
@@ -86,14 +95,14 @@ crewplus-base/ # GitHub repo name
|
|
|
86
95
|
│ └── gemini_chat_model.py
|
|
87
96
|
│ └── model_load_balancer.py
|
|
88
97
|
│ └── ...
|
|
89
|
-
│ └── vectorstores/
|
|
90
|
-
│ └── ...
|
|
91
98
|
│ └── core/
|
|
92
99
|
│ └── __init__.py
|
|
93
100
|
│ └── config.py
|
|
94
101
|
│ └── ...
|
|
95
102
|
├── tests/
|
|
96
103
|
│ └── ...
|
|
104
|
+
├── docs/
|
|
105
|
+
│ └── ...
|
|
97
106
|
└── notebooks/
|
|
98
107
|
└── ...
|
|
99
108
|
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
crewplus-0.1.3.dist-info/METADATA,sha256=a8E2C9PR7Y1V8tay8j3bXj47OoAlR2CFyPSemSZEQm4,4830
|
|
2
|
+
crewplus-0.1.3.dist-info/WHEEL,sha256=tSfRZzRHthuv7vxpI4aehrdN9scLjk-dCJkPLzkHxGg,90
|
|
3
|
+
crewplus-0.1.3.dist-info/entry_points.txt,sha256=6OYgBcLyFCUgeqLgnvMyOJxPCWzgy7se4rLPKtNonMs,34
|
|
4
|
+
crewplus-0.1.3.dist-info/licenses/LICENSE,sha256=2_NHSHRTKB_cTcT_GXgcenOCtIZku8j343mOgAguTfc,1087
|
|
5
|
+
crewplus/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
crewplus/services/__init__.py,sha256=MmH2v3N0ZMsuqFNAupkXENjUqvgf5ehQ99H6EzPqLZU,48
|
|
7
|
+
crewplus/services/gemini_chat_model.py,sha256=EjpagLz8JAud9siLUWMBlbvqmZ-_NU04EE0GhBbc5LY,26204
|
|
8
|
+
crewplus/services/model_load_balancer.py,sha256=bJpSgCGPWWT1yD_nYshIPngr8Xmdq1gfq8lJ1hOEGbM,8673
|
|
9
|
+
crewplus-0.1.3.dist-info/RECORD,,
|
crewplus-0.1.2.dist-info/RECORD
DELETED
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
crewplus-0.1.2.dist-info/METADATA,sha256=UYedq0IKvsbDfPdxX5G-fXweBIaPTtSJBBu4Mtc4hqI,4462
|
|
2
|
-
crewplus-0.1.2.dist-info/WHEEL,sha256=tSfRZzRHthuv7vxpI4aehrdN9scLjk-dCJkPLzkHxGg,90
|
|
3
|
-
crewplus-0.1.2.dist-info/entry_points.txt,sha256=6OYgBcLyFCUgeqLgnvMyOJxPCWzgy7se4rLPKtNonMs,34
|
|
4
|
-
crewplus-0.1.2.dist-info/licenses/LICENSE,sha256=2_NHSHRTKB_cTcT_GXgcenOCtIZku8j343mOgAguTfc,1087
|
|
5
|
-
crewplus/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
-
crewplus/services/__init__.py,sha256=MmH2v3N0ZMsuqFNAupkXENjUqvgf5ehQ99H6EzPqLZU,48
|
|
7
|
-
crewplus/services/gemini_chat_model.py,sha256=idibpvbF9asBdJByR2XCHcdd5XHwBnVs_U0udOFXhN4,15081
|
|
8
|
-
crewplus/services/model_load_balancer.py,sha256=bJpSgCGPWWT1yD_nYshIPngr8Xmdq1gfq8lJ1hOEGbM,8673
|
|
9
|
-
crewplus-0.1.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|