donkit-llm 0.1.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.
@@ -0,0 +1,478 @@
1
+ import json
2
+ import base64
3
+ from typing import AsyncIterator
4
+
5
+ import google.genai as genai
6
+ from google.genai.types import Blob, Content, FunctionDeclaration, Part
7
+ from google.genai.types import Tool as GeminiTool
8
+ from google.oauth2 import service_account
9
+
10
+ from .model_abstract import (
11
+ ContentType,
12
+ EmbeddingRequest,
13
+ EmbeddingResponse,
14
+ FunctionCall,
15
+ GenerateRequest,
16
+ GenerateResponse,
17
+ LLMModelAbstract,
18
+ Message,
19
+ ModelCapability,
20
+ StreamChunk,
21
+ Tool,
22
+ ToolCall,
23
+ )
24
+
25
+
26
+ class VertexAIModel(LLMModelAbstract):
27
+ """
28
+ Vertex AI model implementation using google-genai SDK.
29
+
30
+ Supports all models available on Vertex AI:
31
+ - Gemini models (gemini-1.5-pro, gemini-1.5-flash, gemini-2.0-flash-exp)
32
+ - Claude models via Vertex AI (claude-3-5-sonnet-v2@20241022, etc.)
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ project_id: str,
38
+ model_name: str = "gemini-2.5-flash",
39
+ location: str = "us-central1",
40
+ credentials: dict | None = None,
41
+ ):
42
+ """
43
+ Initialize Vertex AI model via google-genai SDK.
44
+
45
+ Args:
46
+ model_name: Model identifier (e.g., "gemini-2.0-flash-exp", "claude-3-5-sonnet-v2@20241022")
47
+ project_id: GCP project ID
48
+ location: GCP location (us-central1 for Gemini, us-east5 for Claude)
49
+ credentials: Optional service account credentials dict
50
+ """
51
+ self._model_name = model_name
52
+ self._project_id = project_id
53
+ self._location = location
54
+
55
+ # Initialize client with Vertex AI
56
+ client_kwargs = {
57
+ "vertexai": True,
58
+ "project": project_id,
59
+ "location": location,
60
+ }
61
+
62
+ # Add credentials if provided
63
+ if credentials:
64
+ creds = service_account.Credentials.from_service_account_info(
65
+ credentials, scopes=["https://www.googleapis.com/auth/cloud-platform"]
66
+ )
67
+ client_kwargs["credentials"] = creds
68
+
69
+ self.client = genai.Client(**client_kwargs)
70
+ self._capabilities = self._determine_capabilities()
71
+
72
+ def _determine_capabilities(self) -> ModelCapability:
73
+ """Determine capabilities based on model name."""
74
+ caps = (
75
+ ModelCapability.TEXT_GENERATION
76
+ | ModelCapability.STREAMING
77
+ | ModelCapability.STRUCTURED_OUTPUT
78
+ | ModelCapability.TOOL_CALLING
79
+ | ModelCapability.VISION
80
+ | ModelCapability.MULTIMODAL_INPUT
81
+ | ModelCapability.AUDIO_INPUT
82
+ )
83
+ return caps
84
+
85
+ @property
86
+ def model_name(self) -> str:
87
+ return self._model_name
88
+
89
+ @model_name.setter
90
+ def model_name(self, value: str):
91
+ self._model_name = value
92
+ self._capabilities = self._determine_capabilities()
93
+
94
+ @property
95
+ def capabilities(self) -> ModelCapability:
96
+ return self._capabilities
97
+
98
+ def _convert_message(self, msg: Message) -> Content:
99
+ """Convert internal Message to Vertex AI Content format."""
100
+ parts = []
101
+
102
+ if isinstance(msg.content, str):
103
+ parts.append(Part(text=msg.content))
104
+ else:
105
+ # Multimodal content
106
+ for part in msg.content:
107
+ if part.type == ContentType.TEXT:
108
+ parts.append(Part(text=part.content))
109
+ elif part.type == ContentType.IMAGE_URL:
110
+ # For URLs, we'd need to fetch and convert to inline data
111
+ parts.append(
112
+ Part(
113
+ inline_data=Blob(
114
+ mime_type=part.mime_type or "image/jpeg",
115
+ data=part.content.encode(),
116
+ )
117
+ )
118
+ )
119
+ elif part.type == ContentType.IMAGE_BASE64:
120
+ # part.content is base64 string; Vertex needs raw bytes
121
+ raw = base64.b64decode(part.content, validate=True)
122
+ parts.append(
123
+ Part(
124
+ inline_data=Blob(
125
+ mime_type=part.mime_type or "image/png",
126
+ data=raw,
127
+ )
128
+ )
129
+ )
130
+ elif part.type == ContentType.AUDIO_BASE64:
131
+ raw = base64.b64decode(part.content, validate=True)
132
+ parts.append(
133
+ Part(
134
+ inline_data=Blob(
135
+ mime_type=part.mime_type or "audio/wav",
136
+ data=raw,
137
+ )
138
+ )
139
+ )
140
+ elif part.type == ContentType.FILE_BASE64:
141
+ raw = base64.b64decode(part.content, validate=True)
142
+ parts.append(
143
+ Part(
144
+ inline_data=Blob(
145
+ mime_type=part.mime_type or "application/octet-stream",
146
+ data=raw,
147
+ )
148
+ )
149
+ )
150
+ return Content(role=msg.role, parts=parts)
151
+
152
+ def _convert_tools(self, tools: list[Tool]) -> list[GeminiTool]:
153
+ """Convert internal Tool definitions to Vertex AI format."""
154
+ function_declarations = []
155
+ for tool in tools:
156
+ func_def = tool.function
157
+ # Clean schema: remove $ref and $defs (Vertex AI doesn't support them)
158
+ parameters = self._clean_json_schema(func_def.parameters)
159
+
160
+ function_declarations.append(
161
+ FunctionDeclaration(
162
+ name=func_def.name,
163
+ description=func_def.description,
164
+ parameters=parameters,
165
+ )
166
+ )
167
+
168
+ return [GeminiTool(function_declarations=function_declarations)]
169
+
170
+ def _clean_json_schema(self, schema: dict) -> dict:
171
+ """
172
+ Remove $ref and $defs from JSON Schema as Vertex AI doesn't support them.
173
+ """
174
+ if not isinstance(schema, dict):
175
+ return schema
176
+
177
+ cleaned = {}
178
+ for key, value in schema.items():
179
+ if key in ("$ref", "$defs", "definitions"):
180
+ continue
181
+ if isinstance(value, dict):
182
+ cleaned[key] = self._clean_json_schema(value)
183
+ elif isinstance(value, list):
184
+ cleaned[key] = [
185
+ self._clean_json_schema(item) if isinstance(item, dict) else item
186
+ for item in value
187
+ ]
188
+ else:
189
+ cleaned[key] = value
190
+
191
+ return cleaned
192
+
193
+ async def generate(self, request: GenerateRequest) -> GenerateResponse:
194
+ """Generate a response using Vertex AI."""
195
+ await self.validate_request(request)
196
+
197
+ # Separate system message from conversation
198
+ system_instruction = None
199
+ messages = []
200
+ for msg in request.messages:
201
+ if msg.role == "system":
202
+ system_instruction = msg.content if isinstance(msg.content, str) else ""
203
+ else:
204
+ messages.append(self._convert_message(msg))
205
+
206
+ config_kwargs = {}
207
+ if request.temperature is not None:
208
+ config_kwargs["temperature"] = request.temperature
209
+ if request.max_tokens is not None:
210
+ config_kwargs["max_output_tokens"] = request.max_tokens
211
+ if request.top_p is not None:
212
+ config_kwargs["top_p"] = request.top_p
213
+ if request.stop:
214
+ config_kwargs["stop_sequences"] = request.stop
215
+ if request.response_format:
216
+ # Vertex AI uses response_mime_type and response_schema
217
+ config_kwargs["response_mime_type"] = "application/json"
218
+ if "schema" in request.response_format:
219
+ config_kwargs["response_schema"] = self._clean_json_schema(
220
+ request.response_format["schema"]
221
+ )
222
+
223
+ # Build config object
224
+ config = (
225
+ genai.types.GenerateContentConfig(**config_kwargs)
226
+ if config_kwargs
227
+ else None
228
+ )
229
+
230
+ # Add tools to config if present
231
+ if request.tools:
232
+ if config is None:
233
+ config = genai.types.GenerateContentConfig()
234
+ config.tools = self._convert_tools(request.tools)
235
+
236
+ # Add system instruction to config if present
237
+ if system_instruction:
238
+ if config is None:
239
+ config = genai.types.GenerateContentConfig()
240
+ config.system_instruction = system_instruction
241
+
242
+ response = await self.client.aio.models.generate_content(
243
+ model=self._model_name,
244
+ contents=messages,
245
+ config=config,
246
+ )
247
+ # Extract content
248
+ content = None
249
+ if response.text:
250
+ content = response.text
251
+
252
+ # Extract tool calls
253
+ tool_calls = None
254
+ if response.candidates and response.candidates[0].content.parts:
255
+ function_calls = []
256
+ for part in response.candidates[0].content.parts:
257
+ if not hasattr(part, "function_call") or not part.function_call:
258
+ continue
259
+ fc = part.function_call
260
+ args_dict = dict(fc.args) if fc.args else {}
261
+ function_calls.append(
262
+ ToolCall(
263
+ id=fc.name,
264
+ type="function",
265
+ function=FunctionCall(
266
+ name=fc.name,
267
+ arguments=json.dumps(args_dict),
268
+ ),
269
+ )
270
+ )
271
+ if function_calls:
272
+ tool_calls = function_calls
273
+
274
+ # Extract finish reason
275
+ finish_reason = None
276
+ if response.candidates:
277
+ finish_reason = str(response.candidates[0].finish_reason)
278
+
279
+ # Extract usage
280
+ usage = None
281
+ if response.usage_metadata:
282
+ usage = {
283
+ "prompt_tokens": response.usage_metadata.prompt_token_count,
284
+ "completion_tokens": response.usage_metadata.candidates_token_count,
285
+ "total_tokens": response.usage_metadata.total_token_count,
286
+ }
287
+
288
+ return GenerateResponse(
289
+ content=content,
290
+ tool_calls=tool_calls,
291
+ finish_reason=finish_reason,
292
+ usage=usage,
293
+ )
294
+
295
+ async def generate_stream(
296
+ self, request: GenerateRequest
297
+ ) -> AsyncIterator[StreamChunk]:
298
+ """Generate a streaming response using Vertex AI."""
299
+ await self.validate_request(request)
300
+
301
+ # Separate system message from conversation
302
+ system_instruction = None
303
+ messages = []
304
+ for msg in request.messages:
305
+ if msg.role == "system":
306
+ system_instruction = msg.content if isinstance(msg.content, str) else ""
307
+ else:
308
+ messages.append(self._convert_message(msg))
309
+
310
+ config_kwargs = {}
311
+ if request.temperature is not None:
312
+ config_kwargs["temperature"] = request.temperature
313
+ if request.max_tokens is not None:
314
+ config_kwargs["max_output_tokens"] = request.max_tokens
315
+ if request.top_p is not None:
316
+ config_kwargs["top_p"] = request.top_p
317
+ if request.stop:
318
+ config_kwargs["stop_sequences"] = request.stop
319
+ if request.response_format:
320
+ config_kwargs["response_mime_type"] = "application/json"
321
+ if "schema" in request.response_format:
322
+ config_kwargs["response_schema"] = self._clean_json_schema(
323
+ request.response_format["schema"]
324
+ )
325
+
326
+ # Build config object
327
+ config = (
328
+ genai.types.GenerateContentConfig(**config_kwargs)
329
+ if config_kwargs
330
+ else None
331
+ )
332
+
333
+ # Add tools to config if present
334
+ if request.tools:
335
+ if config is None:
336
+ config = genai.types.GenerateContentConfig()
337
+ config.tools = self._convert_tools(request.tools)
338
+
339
+ # Add system instruction to config if present
340
+ if system_instruction:
341
+ if config is None:
342
+ config = genai.types.GenerateContentConfig()
343
+ config.system_instruction = system_instruction
344
+
345
+ model_name = self._model_name
346
+ stream = await self.client.aio.models.generate_content_stream(
347
+ model=model_name,
348
+ contents=messages,
349
+ config=config,
350
+ )
351
+
352
+ async for chunk in stream:
353
+ content = None
354
+ if chunk.text:
355
+ content = chunk.text
356
+
357
+ # Extract tool calls from chunk
358
+ tool_calls = None
359
+ if chunk.candidates and chunk.candidates[0].content.parts:
360
+ function_calls = []
361
+ for part in chunk.candidates[0].content.parts:
362
+ if not hasattr(part, "function_call") or not part.function_call:
363
+ continue
364
+ fc = part.function_call
365
+ args_dict = dict(fc.args) if fc.args else {}
366
+ function_calls.append(
367
+ ToolCall(
368
+ id=fc.name,
369
+ type="function",
370
+ function=FunctionCall(
371
+ name=fc.name,
372
+ arguments=json.dumps(args_dict),
373
+ ),
374
+ )
375
+ )
376
+ if function_calls:
377
+ tool_calls = function_calls
378
+
379
+ finish_reason = None
380
+ if chunk.candidates:
381
+ finish_reason = str(chunk.candidates[0].finish_reason)
382
+
383
+ if content or tool_calls or finish_reason:
384
+ yield StreamChunk(
385
+ content=content,
386
+ tool_calls=tool_calls,
387
+ finish_reason=finish_reason,
388
+ )
389
+
390
+
391
+ class VertexEmbeddingModel(LLMModelAbstract):
392
+ """
393
+ Vertex AI embedding model using google-genai SDK with advanced features.
394
+ """
395
+
396
+ def __init__(
397
+ self,
398
+ project_id: str,
399
+ model_name: str = "text-multilingual-embedding-002",
400
+ location: str = "us-central1",
401
+ credentials: dict | None = None,
402
+ output_dimensionality: int | None = None,
403
+ batch_size: int = 100,
404
+ task_type: str = "RETRIEVAL_DOCUMENT",
405
+ ):
406
+ self._model_name = model_name
407
+ self._project_id = project_id
408
+ self._location = location
409
+ self._output_dimensionality = output_dimensionality
410
+ self._batch_size = batch_size
411
+ self._task_type = task_type
412
+
413
+ client_kwargs = {
414
+ "vertexai": True,
415
+ "project": project_id,
416
+ "location": location,
417
+ }
418
+
419
+ if credentials:
420
+ creds = service_account.Credentials.from_service_account_info(
421
+ credentials, scopes=["https://www.googleapis.com/auth/cloud-platform"]
422
+ )
423
+ client_kwargs["credentials"] = creds
424
+
425
+ self.client = genai.Client(**client_kwargs)
426
+
427
+ @property
428
+ def model_name(self) -> str:
429
+ return self._model_name
430
+
431
+ @property
432
+ def capabilities(self) -> ModelCapability:
433
+ return ModelCapability.EMBEDDINGS
434
+
435
+ async def generate(self, request: GenerateRequest) -> GenerateResponse:
436
+ raise NotImplementedError("Embedding models do not support text generation")
437
+
438
+ async def generate_stream(
439
+ self, request: GenerateRequest
440
+ ) -> AsyncIterator[StreamChunk]:
441
+ raise NotImplementedError("Embedding models do not support text generation")
442
+
443
+ async def embed(self, request: EmbeddingRequest) -> EmbeddingResponse:
444
+ inputs = [request.input] if isinstance(request.input, str) else request.input
445
+
446
+ all_embeddings: list[list[float]] = []
447
+
448
+ for i in range(0, len(inputs), self._batch_size):
449
+ batch = inputs[i : i + self._batch_size]
450
+
451
+ config_kwargs = {}
452
+ if self._output_dimensionality:
453
+ config_kwargs["output_dimensionality"] = self._output_dimensionality
454
+ if self._task_type:
455
+ config_kwargs["task_type"] = self._task_type
456
+
457
+ config = (
458
+ genai.types.EmbedContentConfig(**config_kwargs)
459
+ if config_kwargs
460
+ else None
461
+ )
462
+
463
+ try:
464
+ response = await self.client.aio.models.embed_content(
465
+ model=self._model_name,
466
+ contents=batch,
467
+ config=config,
468
+ )
469
+ except Exception as e:
470
+ raise Exception(f"Failed to embed batch: {e}")
471
+
472
+ embeddings = [emb.values for emb in response.embeddings]
473
+ all_embeddings.extend(embeddings)
474
+
475
+ return EmbeddingResponse(
476
+ embeddings=all_embeddings,
477
+ usage=None,
478
+ )
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.3
2
+ Name: donkit-llm
3
+ Version: 0.1.0
4
+ Summary: Unified LLM model implementations for Donkit (OpenAI, Azure OpenAI, Claude, Vertex AI)
5
+ License: MIT
6
+ Author: Donkit AI
7
+ Author-email: opensource@donkit.ai
8
+ Requires-Python: >=3.12,<4.0
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Requires-Dist: anthropic[vertex] (>=0.42.0,<0.43.0)
14
+ Requires-Dist: google-auth (>=2.0.0,<3.0.0)
15
+ Requires-Dist: google-genai (>=1.38.0,<2.0.0)
16
+ Requires-Dist: openai (>=2.1.0,<3.0.0)
17
+ Requires-Dist: pydantic (>=2.8.0,<3.0.0)
@@ -0,0 +1,9 @@
1
+ donkit/llm/__init__.py,sha256=VLnsTe_cB_FkKQbutGkEGVXOGevbrfgICRjqTXq4nsA,1193
2
+ donkit/llm/claude_model.py,sha256=RCvqaFSIaWJvcmW7y706Kc8l6YKdP0xR3chYRR-otV8,16589
3
+ donkit/llm/factory.py,sha256=PRRIY3sMOxgBTX-5Ecci62TWW9k9MP7lyhHyoo4dZeM,5676
4
+ donkit/llm/model_abstract.py,sha256=-v_hDMzKS6_sZ3kWY6WUIP1LoAvXQvn7YZC9Hy4lWWo,7861
5
+ donkit/llm/openai_model.py,sha256=nGTPjzsldILsFG6GlU5ZNwsTfaWT9rsApvpNpxLhbAc,19395
6
+ donkit/llm/vertex_model.py,sha256=z14briPYZt3qmxJZdJYvPKyGSHZxLdNKiF1N2_FNoZc,17066
7
+ donkit_llm-0.1.0.dist-info/METADATA,sha256=rIuOQ0ZALJXKIxqMbqA0_lJn2puEaHfV5TXnegO-CaY,668
8
+ donkit_llm-0.1.0.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
9
+ donkit_llm-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.1.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any