generic-llm-api-client 0.1.1__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,28 @@
1
+ """
2
+ DeepSeek-specific client using OpenAI-compatible API.
3
+
4
+ DeepSeek uses an OpenAI-compatible API, so we can reuse the OpenAIClient
5
+ with a custom base URL.
6
+ """
7
+
8
+ from .openai_client import OpenAIClient
9
+
10
+
11
+ class DeepSeekClient(OpenAIClient):
12
+ """
13
+ DeepSeek client using OpenAI-compatible API.
14
+
15
+ This client simply extends OpenAIClient with a custom base URL.
16
+ """
17
+
18
+ PROVIDER_ID = "deepseek"
19
+ SUPPORTS_MULTIMODAL = True
20
+
21
+ def _init_client(self):
22
+ """Initialize the client with DeepSeek's base URL."""
23
+ # Override base_url if not provided
24
+ if not self.base_url:
25
+ self.base_url = "https://api.deepseek.com/v1"
26
+
27
+ # Call parent initialization
28
+ super()._init_client()
@@ -0,0 +1,269 @@
1
+ """
2
+ Google Gemini-specific implementation of the BaseAIClient.
3
+
4
+ This module provides the GeminiClient class, which implements the BaseAIClient
5
+ interface specifically for Google's Gemini API, supporting both text-only and
6
+ multimodal (text + images) content.
7
+ """
8
+
9
+ import base64
10
+ import json
11
+ import logging
12
+ from typing import List, Tuple, Any, Optional
13
+
14
+ import google.genai as genai
15
+ from google.genai.types import GenerateContentConfig, Part
16
+ import requests
17
+
18
+ from .base_client import BaseAIClient
19
+ from .response import LLMResponse, Usage
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class GeminiClient(BaseAIClient):
25
+ """
26
+ Google Gemini-specific implementation of the BaseAIClient.
27
+
28
+ This class implements the BaseAIClient interface for Google's Gemini API,
29
+ handling both text-only and multimodal (text + images) requests.
30
+
31
+ Key features:
32
+ - Full support for multimodal content via Gemini Pro Vision models
33
+ - Support for Gemini-specific parameters like top_k, top_p
34
+ - Structured output via response schema
35
+ """
36
+
37
+ PROVIDER_ID = "genai"
38
+ SUPPORTS_MULTIMODAL = True
39
+
40
+ def _init_client(self):
41
+ """Initialize Gemini API client with the provided API key."""
42
+ self.api_client = genai.Client(api_key=self.api_key)
43
+
44
+ def _prepare_content_with_images(self, prompt: str, images: List[str]) -> List[Any]:
45
+ """
46
+ Prepare Gemini content with text and images.
47
+
48
+ Args:
49
+ prompt: The text prompt
50
+ images: List of image paths/URLs
51
+
52
+ Returns:
53
+ List of content parts for Gemini API
54
+ """
55
+ contents = [prompt]
56
+
57
+ # Add images if any
58
+ for resource in images:
59
+ try:
60
+ if self.is_url(resource):
61
+ # For URLs, fetch the image
62
+ response = requests.get(resource)
63
+ if response.status_code == 200:
64
+ image_data = response.content
65
+ else:
66
+ logger.error(
67
+ f"Failed to fetch image from URL {resource}: {response.status_code}"
68
+ )
69
+ continue
70
+ else:
71
+ # For local files, read the image
72
+ with open(resource, "rb") as f:
73
+ image_data = f.read()
74
+
75
+ # Detect MIME type from file extension
76
+ from .utils import detect_image_mime_type
77
+
78
+ mime_type = detect_image_mime_type(resource)
79
+
80
+ # Create image part
81
+ image_part = Part(
82
+ inline_data={
83
+ "mime_type": mime_type,
84
+ "data": base64.b64encode(image_data).decode("utf-8"),
85
+ }
86
+ )
87
+ contents.append(image_part)
88
+ except Exception as e:
89
+ logger.error(f"Error processing image {resource}: {e}")
90
+
91
+ return contents
92
+
93
+ @staticmethod
94
+ def _remove_defaults_from_schema(schema):
95
+ """Recursively remove default values from JSON schema (for GenAI compatibility)."""
96
+ if isinstance(schema, dict):
97
+ if "default" in schema:
98
+ del schema["default"]
99
+ for key, value in schema.items():
100
+ if isinstance(value, (dict, list)):
101
+ GeminiClient._remove_defaults_from_schema(value)
102
+ elif isinstance(schema, list):
103
+ for item in schema:
104
+ if isinstance(item, (dict, list)):
105
+ GeminiClient._remove_defaults_from_schema(item)
106
+
107
+ def _do_prompt(
108
+ self,
109
+ model: str,
110
+ prompt: str,
111
+ images: List[str],
112
+ system_prompt: str,
113
+ response_format: Optional[Any],
114
+ **kwargs,
115
+ ) -> LLMResponse:
116
+ """
117
+ Send a prompt to the Gemini model and get the response.
118
+
119
+ Args:
120
+ model: The Gemini model identifier (e.g., "gemini-pro", "gemini-pro-vision")
121
+ prompt: The text prompt to send
122
+ images: List of image paths/URLs
123
+ system_prompt: System prompt (prepended to prompt for Gemini)
124
+ response_format: Optional Pydantic model for structured output
125
+ **kwargs: Additional Gemini-specific parameters
126
+
127
+ Returns:
128
+ LLMResponse object with the provider's response
129
+ """
130
+ # Prepend system prompt to user prompt for Gemini
131
+ full_prompt = f"{system_prompt}\n\n{prompt}" if system_prompt else prompt
132
+
133
+ # Prepare content with images
134
+ contents = self._prepare_content_with_images(full_prompt, images)
135
+
136
+ # Build generation config
137
+ generation_config = {}
138
+
139
+ # Extract Gemini-specific parameters
140
+ optional_params = [
141
+ "temperature",
142
+ "top_p",
143
+ "top_k",
144
+ "max_output_tokens",
145
+ "candidate_count",
146
+ "stop_sequences",
147
+ ]
148
+ for param in optional_params:
149
+ value = kwargs.get(param, self.settings.get(param))
150
+ if value is not None:
151
+ generation_config[param] = value
152
+
153
+ # Handle structured output
154
+ if response_format and hasattr(response_format, "model_json_schema"):
155
+ schema = response_format.model_json_schema()
156
+ # GenAI doesn't support default values in schema
157
+ self._remove_defaults_from_schema(schema)
158
+
159
+ generation_config["response_mime_type"] = "application/json"
160
+ generation_config["response_schema"] = schema
161
+
162
+ # Generate content
163
+ # Try different approaches based on what parameters are available
164
+ try:
165
+ if generation_config:
166
+ # Try passing config as a GenerateContentConfig object
167
+ config = GenerateContentConfig(**generation_config)
168
+ raw_response = self.api_client.models.generate_content(
169
+ model=model, contents=contents, config=config
170
+ )
171
+ else:
172
+ raw_response = self.api_client.models.generate_content(
173
+ model=model, contents=contents
174
+ )
175
+ except TypeError as e:
176
+ # If config parameter doesn't work, try passing generation_config directly
177
+ if "config" in str(e):
178
+ logger.warning(f"Config parameter not accepted, trying alternative API: {e}")
179
+ if generation_config:
180
+ raw_response = self.api_client.models.generate_content(
181
+ model=model, contents=contents, generation_config=generation_config
182
+ )
183
+ else:
184
+ raw_response = self.api_client.models.generate_content(
185
+ model=model, contents=contents
186
+ )
187
+ else:
188
+ raise
189
+
190
+ return self._create_response_from_raw(raw_response, model, response_format)
191
+
192
+ def _create_response_from_raw(
193
+ self, raw_response: Any, model: str, response_format: Optional[Any]
194
+ ) -> LLMResponse:
195
+ """
196
+ Create LLMResponse from Gemini raw response.
197
+
198
+ Args:
199
+ raw_response: Raw Gemini response object
200
+ model: Model identifier
201
+ response_format: Pydantic model if structured output was requested
202
+
203
+ Returns:
204
+ LLMResponse object
205
+ """
206
+ text = raw_response.text if hasattr(raw_response, "text") else ""
207
+
208
+ # If response_format was provided, try to validate
209
+ if response_format and hasattr(response_format, "model_json_schema") and text:
210
+ try:
211
+ json_data = json.loads(text)
212
+ validated = response_format(**json_data)
213
+ text = validated.model_dump_json()
214
+ except Exception as e:
215
+ logger.warning(f"Failed to validate Gemini structured response: {e}")
216
+ # Keep original text
217
+
218
+ usage = Usage()
219
+ if hasattr(raw_response, "usage_metadata"):
220
+ usage = Usage(
221
+ input_tokens=raw_response.usage_metadata.prompt_token_count,
222
+ output_tokens=raw_response.usage_metadata.candidates_token_count,
223
+ total_tokens=raw_response.usage_metadata.total_token_count,
224
+ )
225
+
226
+ # Determine finish reason
227
+ finish_reason = "unknown"
228
+ if hasattr(raw_response, "candidates") and raw_response.candidates:
229
+ candidate = raw_response.candidates[0]
230
+ if hasattr(candidate, "finish_reason"):
231
+ # Extract the value from the enum (e.g., FinishReason.STOP -> 'STOP')
232
+ fr = candidate.finish_reason
233
+ if hasattr(fr, "value"):
234
+ finish_reason = fr.value
235
+ elif hasattr(fr, "name"):
236
+ finish_reason = fr.name
237
+ else:
238
+ finish_reason = str(fr)
239
+
240
+ return LLMResponse(
241
+ text=text,
242
+ model=model,
243
+ provider=self.PROVIDER_ID,
244
+ finish_reason=finish_reason,
245
+ usage=usage,
246
+ raw_response=raw_response,
247
+ )
248
+
249
+ def get_model_list(self) -> List[Tuple[str, Optional[str]]]:
250
+ """
251
+ Get a list of available models from Gemini.
252
+
253
+ Returns:
254
+ List of tuples (model_id, created_date)
255
+ """
256
+ model_list = []
257
+
258
+ try:
259
+ raw_list = self.api_client.models.list()
260
+ for model in raw_list:
261
+ model_name = model.name if hasattr(model, "name") else str(model)
262
+ # Remove 'models/' prefix if present
263
+ if model_name.startswith("models/"):
264
+ model_name = model_name[7:]
265
+ model_list.append((model_name, None))
266
+ except Exception as e:
267
+ logger.error(f"Error listing Gemini models: {e}")
268
+
269
+ return model_list
@@ -0,0 +1,191 @@
1
+ """
2
+ Mistral-specific implementation of the BaseAIClient.
3
+
4
+ This module provides the MistralClient class, which implements the BaseAIClient
5
+ interface specifically for Mistral's API, supporting both text-only and multimodal
6
+ interactions.
7
+ """
8
+
9
+ import base64
10
+ import json
11
+ import logging
12
+ from typing import List, Tuple, Any, Optional
13
+
14
+ from mistralai import Mistral
15
+
16
+ from .base_client import BaseAIClient
17
+ from .response import LLMResponse, Usage
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class MistralClient(BaseAIClient):
23
+ """
24
+ Mistral-specific implementation of the BaseAIClient.
25
+
26
+ This class implements the BaseAIClient interface for Mistral's API,
27
+ supporting text-only and multimodal requests via the chat completion API.
28
+
29
+ Key features:
30
+ - Integration with Mistral's chat completion API
31
+ - Support for multimodal content (text + images)
32
+ - Support for Mistral-specific parameters
33
+ - Structured output via JSON mode
34
+ """
35
+
36
+ PROVIDER_ID = "mistral"
37
+ SUPPORTS_MULTIMODAL = True # Mistral supports images
38
+
39
+ def _init_client(self):
40
+ """Initialize the Mistral client with the provided API key."""
41
+ self.api_client = Mistral(api_key=self.api_key)
42
+
43
+ def _prepare_content_with_images(self, prompt: str, images: List[str]) -> List[dict]:
44
+ """
45
+ Prepare Mistral content with text and images.
46
+
47
+ Args:
48
+ prompt: The text prompt
49
+ images: List of image paths/URLs
50
+
51
+ Returns:
52
+ List of content blocks for Mistral API
53
+ """
54
+ content = [{"type": "text", "text": prompt}]
55
+
56
+ # Add images if any
57
+ for resource in images:
58
+ try:
59
+ if self.is_url(resource):
60
+ # For URLs, use directly
61
+ data_uri = resource
62
+ else:
63
+ # For local files, encode as base64
64
+ with open(resource, "rb") as image_file:
65
+ base64_image = base64.b64encode(image_file.read()).decode("utf-8")
66
+
67
+ # Detect MIME type from file extension
68
+ from .utils import detect_image_mime_type
69
+
70
+ mime_type = detect_image_mime_type(resource)
71
+
72
+ data_uri = f"data:{mime_type};base64,{base64_image}"
73
+
74
+ content.append({"type": "image_url", "image_url": {"url": data_uri}})
75
+ except Exception as e:
76
+ logger.error(f"Error processing image {resource}: {e}")
77
+
78
+ return content
79
+
80
+ def _do_prompt(
81
+ self,
82
+ model: str,
83
+ prompt: str,
84
+ images: List[str],
85
+ system_prompt: str,
86
+ response_format: Optional[Any],
87
+ **kwargs,
88
+ ) -> LLMResponse:
89
+ """
90
+ Send a prompt to the Mistral model and get the response.
91
+
92
+ Args:
93
+ model: The Mistral model identifier
94
+ prompt: The text prompt to send
95
+ images: List of image paths/URLs
96
+ system_prompt: System prompt to use
97
+ response_format: Optional Pydantic model for structured output
98
+ **kwargs: Additional Mistral-specific parameters
99
+
100
+ Returns:
101
+ LLMResponse object with the provider's response
102
+ """
103
+ # Prepare content with images
104
+ content = self._prepare_content_with_images(prompt, images)
105
+
106
+ # Build messages
107
+ messages = [{"role": "user", "content": content}]
108
+
109
+ # Add system message if provided
110
+ if system_prompt:
111
+ messages.insert(0, {"role": "system", "content": system_prompt})
112
+
113
+ params = {"messages": messages, "model": model}
114
+
115
+ # Handle structured output
116
+ if response_format and hasattr(response_format, "model_json_schema"):
117
+ schema = response_format.model_json_schema()
118
+ schema_prompt = (
119
+ f"\n\nReturn a JSON response matching this exact schema: {json.dumps(schema)}"
120
+ )
121
+ messages[-1]["content"] = prompt + schema_prompt
122
+ params["response_format"] = {"type": "json_object"}
123
+
124
+ # Send the request
125
+ raw_response = self.api_client.chat.complete(**params)
126
+
127
+ return self._create_response_from_raw(raw_response, model, response_format)
128
+
129
+ def _create_response_from_raw(
130
+ self, raw_response: Any, model: str, response_format: Optional[Any]
131
+ ) -> LLMResponse:
132
+ """
133
+ Create LLMResponse from Mistral raw response.
134
+
135
+ Args:
136
+ raw_response: Raw Mistral response object
137
+ model: Model identifier
138
+ response_format: Pydantic model if structured output was requested
139
+
140
+ Returns:
141
+ LLMResponse object
142
+ """
143
+ choice = raw_response.choices[0]
144
+ text = choice.message.content if hasattr(choice.message, "content") else ""
145
+
146
+ # If response_format was provided, try to validate
147
+ if response_format and hasattr(response_format, "model_json_schema") and text:
148
+ try:
149
+ json_data = json.loads(text)
150
+ validated = response_format(**json_data)
151
+ text = validated.model_dump_json()
152
+ except Exception as e:
153
+ logger.warning(f"Failed to validate Mistral structured response: {e}")
154
+ # Keep original text
155
+
156
+ usage = Usage()
157
+ if hasattr(raw_response, "usage") and raw_response.usage:
158
+ usage = Usage(
159
+ input_tokens=raw_response.usage.prompt_tokens,
160
+ output_tokens=raw_response.usage.completion_tokens,
161
+ total_tokens=raw_response.usage.total_tokens,
162
+ )
163
+
164
+ finish_reason = choice.finish_reason if hasattr(choice, "finish_reason") else "unknown"
165
+
166
+ return LLMResponse(
167
+ text=text,
168
+ model=model,
169
+ provider=self.PROVIDER_ID,
170
+ finish_reason=finish_reason,
171
+ usage=usage,
172
+ raw_response=raw_response,
173
+ )
174
+
175
+ def get_model_list(self) -> List[Tuple[str, Optional[str]]]:
176
+ """
177
+ Get a list of available models from Mistral.
178
+
179
+ Returns:
180
+ List of tuples (model_id, created_date)
181
+ """
182
+ if self.api_client is None:
183
+ raise ValueError("Mistral client is not initialized.")
184
+
185
+ model_list = []
186
+ raw_list = self.api_client.models.list()
187
+
188
+ for model in raw_list:
189
+ model_list.append((model.id, None))
190
+
191
+ return model_list