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.
ai_client/__init__.py ADDED
@@ -0,0 +1,66 @@
1
+ """
2
+ Unified AI client package for multiple providers.
3
+
4
+ This package provides a standardized interface for interacting with various AI models through
5
+ their respective APIs, with separate implementation files for each provider.
6
+
7
+ Key features:
8
+ - Abstract base client defining the common interface for all AI providers
9
+ - Provider-specific implementations for OpenAI, Google Gemini, Anthropic Claude, Mistral, DeepSeek, and Qwen
10
+ - Support for both text-only and multimodal (text + images) content
11
+ - Consistent LLMResponse format with detailed usage tracking
12
+ - Factory method to create appropriate client based on provider
13
+ - Built-in retry logic and rate limiting
14
+ - Optional async support for parallel processing
15
+
16
+ Technical implementation details:
17
+ - OpenAI: Uses the chat completions API with multimodal support and structured output
18
+ - Google Gemini: Uses the GenerativeModel class with image support
19
+ - Anthropic Claude: Uses the messages API with tool-based structured output
20
+ - Mistral: Uses the chat completion API with multimodal support
21
+ - DeepSeek: OpenAI-compatible API with custom base URL
22
+ - Qwen: OpenAI-compatible API with custom base URL
23
+ - OpenRouter/sciCORE: Use OpenAI client with custom base URLs
24
+ """
25
+
26
+ from .base_client import BaseAIClient, create_ai_client
27
+ from .openai_client import OpenAIClient
28
+ from .gemini_client import GeminiClient
29
+ from .claude_client import ClaudeClient
30
+ from .mistral_client import MistralClient
31
+ from .deepseek_client import DeepSeekClient
32
+ from .qwen_client import QwenClient
33
+ from .response import LLMResponse, Usage
34
+ from .utils import (
35
+ retry_with_exponential_backoff,
36
+ is_rate_limit_error,
37
+ detect_image_mime_type,
38
+ RateLimitError,
39
+ APIError,
40
+ )
41
+
42
+ __version__ = "0.1.1"
43
+
44
+ __all__ = [
45
+ # Core classes
46
+ "BaseAIClient",
47
+ "create_ai_client",
48
+ # Provider-specific clients
49
+ "OpenAIClient",
50
+ "GeminiClient",
51
+ "ClaudeClient",
52
+ "MistralClient",
53
+ "DeepSeekClient",
54
+ "QwenClient",
55
+ # Response and utility classes
56
+ "LLMResponse",
57
+ "Usage",
58
+ # Utility functions and exceptions
59
+ "retry_with_exponential_backoff",
60
+ "is_rate_limit_error",
61
+ "detect_image_mime_type",
62
+ "RateLimitError",
63
+ "APIError",
64
+ # Version
65
+ "__version__",
66
+ ]
@@ -0,0 +1,353 @@
1
+ """
2
+ Abstract base AI client defining the interface for all AI provider implementations.
3
+
4
+ This module provides an abstract base class that defines the common interface
5
+ for all AI provider client implementations. It handles shared functionality
6
+ like timing and basic client lifecycle, while leaving provider-specific
7
+ implementations to subclasses.
8
+ """
9
+
10
+ import abc
11
+ import time
12
+ import asyncio
13
+ from typing import List, Tuple, Any, Optional
14
+ from .response import LLMResponse, Usage
15
+ from .utils import retry_with_exponential_backoff, is_rate_limit_error
16
+
17
+
18
+ class BaseAIClient(abc.ABC):
19
+ """
20
+ Abstract base AI client defining the interface for all provider implementations.
21
+
22
+ This abstract class defines the common methods and properties that all AI
23
+ provider client implementations must support. It handles basic functionality
24
+ like timing, retry logic, async support, and client lifecycle.
25
+
26
+ Key features:
27
+ - Images are passed per-request, avoiding stateful management bugs
28
+ - Returns structured LLMResponse objects with detailed usage info
29
+ - Optional async support for parallel processing
30
+ - Built-in retry logic for rate limiting
31
+
32
+ Attributes:
33
+ PROVIDER_ID (str): The unique identifier for this AI provider
34
+ SUPPORTS_MULTIMODAL (bool): Whether this provider supports multimodal content
35
+ api_key (str): The API key used for authentication
36
+ system_prompt (str): Default system prompt for requests
37
+ settings (dict): Provider-specific settings like temperature, max_tokens, etc.
38
+ base_url (str, optional): Custom base URL for API (e.g., for OpenRouter, sciCORE)
39
+ """
40
+
41
+ PROVIDER_ID = "base"
42
+ SUPPORTS_MULTIMODAL = False
43
+
44
+ def __init__(
45
+ self,
46
+ api_key: str,
47
+ system_prompt: Optional[str] = None,
48
+ base_url: Optional[str] = None,
49
+ **settings,
50
+ ):
51
+ """
52
+ Initialize the AI client base.
53
+
54
+ Args:
55
+ api_key: API key for authentication with the provider
56
+ system_prompt: System prompt/role description for the AI
57
+ base_url: Custom base URL for API endpoints (provider-specific)
58
+ **settings: Additional provider-specific settings like temperature, max_tokens, etc.
59
+ """
60
+ self.init_time = time.time()
61
+ self.end_time = None
62
+ self.api_key = api_key
63
+ self.system_prompt = (
64
+ system_prompt or "A helpful assistant that provides accurate information."
65
+ )
66
+ self.base_url = base_url
67
+ self.settings = settings
68
+ self.api_client = None
69
+
70
+ # Initialize the client implementation
71
+ self._init_client()
72
+
73
+ @abc.abstractmethod
74
+ def _init_client(self):
75
+ """
76
+ Initialize the provider-specific client.
77
+
78
+ This method must be implemented by each provider to handle
79
+ their specific client initialization requirements.
80
+ """
81
+ pass
82
+
83
+ @property
84
+ def elapsed_time(self) -> float:
85
+ """
86
+ Get the elapsed time since client initialization.
87
+
88
+ Returns:
89
+ Elapsed time in seconds
90
+ """
91
+ if self.end_time is None:
92
+ return time.time() - self.init_time
93
+ return self.end_time - self.init_time
94
+
95
+ def end_client(self):
96
+ """
97
+ End the client session and record the end time.
98
+
99
+ This method cleans up the client object and records when the session ended.
100
+ """
101
+ self.api_client = None
102
+ self.end_time = time.time()
103
+
104
+ @staticmethod
105
+ def is_url(resource: str) -> bool:
106
+ """
107
+ Check if a resource is a URL or a local file path.
108
+
109
+ Args:
110
+ resource: The resource string to check
111
+
112
+ Returns:
113
+ True if the resource is a URL, False if it's a file path
114
+ """
115
+ return resource.startswith(("http://", "https://"))
116
+
117
+ def prompt(
118
+ self,
119
+ model: str,
120
+ prompt: str,
121
+ images: Optional[List[str]] = None,
122
+ system_prompt: Optional[str] = None,
123
+ response_format: Optional[Any] = None,
124
+ **kwargs,
125
+ ) -> Tuple[LLMResponse, float]:
126
+ """
127
+ Send a prompt to the AI model and get the response.
128
+
129
+ Args:
130
+ model: The model identifier to use
131
+ prompt: The text prompt to send to the model
132
+ images: Optional list of image paths/URLs to include (if provider supports multimodal)
133
+ system_prompt: Optional system prompt to override the default
134
+ response_format: Optional Pydantic model for structured output
135
+ **kwargs: Additional provider-specific parameters
136
+
137
+ Returns:
138
+ Tuple of (LLMResponse object, elapsed_time_in_seconds)
139
+
140
+ Note:
141
+ Images are NOT stored statefully. Pass them with each request.
142
+ """
143
+ start_time = time.time()
144
+
145
+ # Use provided system prompt or fall back to default
146
+ sys_prompt = system_prompt or self.system_prompt
147
+
148
+ # Handle multimodal content
149
+ image_list = images or []
150
+ if image_list and not self.SUPPORTS_MULTIMODAL:
151
+ # Silently ignore images if provider doesn't support them
152
+ image_list = []
153
+
154
+ # Call provider-specific implementation with retry logic
155
+ try:
156
+ response = self._do_prompt_with_retry(
157
+ model=model,
158
+ prompt=prompt,
159
+ images=image_list,
160
+ system_prompt=sys_prompt,
161
+ response_format=response_format,
162
+ **kwargs,
163
+ )
164
+ except Exception as e:
165
+ # Create error response
166
+ response = self._create_error_response(model, str(e))
167
+
168
+ elapsed_time = time.time() - start_time
169
+ response.duration = elapsed_time
170
+
171
+ return response, elapsed_time
172
+
173
+ def _do_prompt_with_retry(self, **kwargs) -> LLMResponse:
174
+ """
175
+ Execute prompt with retry logic for rate limiting.
176
+
177
+ This wraps the provider-specific _do_prompt method with retry logic.
178
+ """
179
+ # Configure retry for common rate limit errors
180
+ retry_func = retry_with_exponential_backoff(
181
+ self._do_prompt,
182
+ max_retries=3,
183
+ initial_delay=1.0,
184
+ max_delay=60.0,
185
+ retryable_exceptions=(Exception,), # Provider-specific exceptions will be caught
186
+ )
187
+
188
+ return retry_func(**kwargs)
189
+
190
+ @abc.abstractmethod
191
+ def _do_prompt(
192
+ self,
193
+ model: str,
194
+ prompt: str,
195
+ images: List[str],
196
+ system_prompt: str,
197
+ response_format: Optional[Any],
198
+ **kwargs,
199
+ ) -> LLMResponse:
200
+ """
201
+ Provider-specific prompt implementation.
202
+
203
+ Args:
204
+ model: Model identifier
205
+ prompt: Text prompt
206
+ images: List of image paths/URLs
207
+ system_prompt: System prompt to use
208
+ response_format: Optional Pydantic model for structured output
209
+ **kwargs: Provider-specific parameters
210
+
211
+ Returns:
212
+ LLMResponse object with the provider's response
213
+ """
214
+ pass
215
+
216
+ async def prompt_async(
217
+ self,
218
+ model: str,
219
+ prompt: str,
220
+ images: Optional[List[str]] = None,
221
+ system_prompt: Optional[str] = None,
222
+ response_format: Optional[Any] = None,
223
+ **kwargs,
224
+ ) -> Tuple[LLMResponse, float]:
225
+ """
226
+ Async version of prompt() for parallel processing.
227
+
228
+ Args:
229
+ model: The model identifier to use
230
+ prompt: The text prompt to send
231
+ images: Optional list of image paths/URLs
232
+ system_prompt: Optional system prompt override
233
+ response_format: Optional Pydantic model for structured output
234
+ **kwargs: Additional provider-specific parameters
235
+
236
+ Returns:
237
+ Tuple of (LLMResponse object, elapsed_time_in_seconds)
238
+ """
239
+ # Default implementation runs sync version in executor
240
+ loop = asyncio.get_event_loop()
241
+ return await loop.run_in_executor(
242
+ None,
243
+ lambda: self.prompt(model, prompt, images, system_prompt, response_format, **kwargs),
244
+ )
245
+
246
+ def _create_error_response(self, model: str, error_message: str) -> LLMResponse:
247
+ """
248
+ Create an error response when the request fails.
249
+
250
+ Args:
251
+ model: Model identifier
252
+ error_message: Error message
253
+
254
+ Returns:
255
+ LLMResponse with error information
256
+ """
257
+ return LLMResponse(
258
+ text="",
259
+ model=model,
260
+ provider=self.PROVIDER_ID,
261
+ finish_reason="error",
262
+ usage=Usage(),
263
+ raw_response={"error": error_message},
264
+ duration=0.0,
265
+ )
266
+
267
+ @abc.abstractmethod
268
+ def get_model_list(self) -> List[Tuple[str, Optional[str]]]:
269
+ """
270
+ Get a list of available models from the current provider.
271
+
272
+ Returns:
273
+ List of tuples containing (model_id, created_date)
274
+ The created_date may be None for some providers.
275
+ """
276
+ pass
277
+
278
+ def has_multimodal_support(self) -> bool:
279
+ """
280
+ Check if the provider supports multimodal content.
281
+
282
+ Returns:
283
+ True if the provider supports multimodal content, False otherwise
284
+ """
285
+ return self.SUPPORTS_MULTIMODAL
286
+
287
+ def __str__(self):
288
+ """String representation of the AI client."""
289
+ return self.PROVIDER_ID
290
+
291
+
292
+ def create_ai_client(
293
+ provider: str,
294
+ api_key: str,
295
+ system_prompt: Optional[str] = None,
296
+ base_url: Optional[str] = None,
297
+ **settings,
298
+ ) -> BaseAIClient:
299
+ """
300
+ Factory function to create an appropriate AI client for the given provider.
301
+
302
+ Args:
303
+ provider: The AI provider ID ('openai', 'genai', 'anthropic', 'mistral', etc.)
304
+ api_key: API key for the provider
305
+ system_prompt: System prompt/role description
306
+ base_url: Custom base URL for API (for OpenRouter, sciCORE, etc.)
307
+ **settings: Additional provider-specific settings
308
+
309
+ Returns:
310
+ BaseAIClient: An instance of the appropriate AI client implementation
311
+
312
+ Raises:
313
+ ValueError: If an unsupported provider is specified
314
+
315
+ Examples:
316
+ >>> # Standard usage
317
+ >>> client = create_ai_client('openai', api_key='sk-...')
318
+ >>> response, duration = client.prompt('gpt-4', 'Hello!')
319
+
320
+ >>> # With custom base URL (OpenRouter)
321
+ >>> client = create_ai_client('openrouter', api_key='sk-...',
322
+ ... base_url='https://openrouter.ai/api/v1')
323
+
324
+ >>> # With multimodal content
325
+ >>> response, duration = client.prompt('gpt-4o', 'Describe this image',
326
+ ... images=['path/to/image.jpg'])
327
+ """
328
+ from .openai_client import OpenAIClient
329
+ from .gemini_client import GeminiClient
330
+ from .claude_client import ClaudeClient
331
+ from .mistral_client import MistralClient
332
+ from .deepseek_client import DeepSeekClient
333
+ from .qwen_client import QwenClient
334
+
335
+ provider_map = {
336
+ "openai": OpenAIClient,
337
+ "genai": GeminiClient,
338
+ "anthropic": ClaudeClient,
339
+ "mistral": MistralClient,
340
+ "deepseek": DeepSeekClient,
341
+ "qwen": QwenClient,
342
+ "openrouter": OpenAIClient, # Uses OpenAI-compatible API
343
+ "scicore": OpenAIClient, # Uses OpenAI-compatible API
344
+ }
345
+
346
+ if provider not in provider_map:
347
+ raise ValueError(
348
+ f"Unsupported AI provider: {provider}. "
349
+ f"Supported providers: {', '.join(provider_map.keys())}"
350
+ )
351
+
352
+ client_class = provider_map[provider]
353
+ return client_class(api_key, system_prompt, base_url, **settings)
@@ -0,0 +1,283 @@
1
+ """
2
+ Anthropic Claude-specific implementation of the BaseAIClient.
3
+
4
+ This module provides the ClaudeClient class, which implements the BaseAIClient
5
+ interface specifically for Anthropic's Claude API, supporting both text and multimodal
6
+ interactions.
7
+ """
8
+
9
+ import base64
10
+ import json
11
+ import logging
12
+ from datetime import datetime
13
+ from typing import List, Tuple, Any, Optional
14
+
15
+ from anthropic import Anthropic
16
+
17
+ from .base_client import BaseAIClient
18
+ from .response import LLMResponse, Usage
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class ClaudeClient(BaseAIClient):
24
+ """
25
+ Anthropic Claude-specific implementation of the BaseAIClient.
26
+
27
+ This class implements the BaseAIClient interface for Anthropic's Claude API,
28
+ supporting both text-only and multimodal requests with tool-based structured output.
29
+
30
+ Key features:
31
+ - Integration with Anthropic's Messages API
32
+ - Support for multimodal content (text + images)
33
+ - Support for Claude-specific parameters like top_p, top_k
34
+ - Structured output via tools API
35
+ """
36
+
37
+ PROVIDER_ID = "anthropic"
38
+ SUPPORTS_MULTIMODAL = True # Claude supports images
39
+
40
+ def _init_client(self):
41
+ """Initialize the Anthropic client with the provided API key."""
42
+ self.api_client = Anthropic(api_key=self.api_key, timeout=300.0) # 5 minutes timeout
43
+
44
+ def _prepare_content_with_images(self, prompt: str, images: List[str]) -> List[dict]:
45
+ """
46
+ Prepare Anthropic 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 blocks for Anthropic API
54
+ """
55
+ content = [{"type": "text", "text": prompt}]
56
+
57
+ # Add images if any
58
+ for resource in images:
59
+ try:
60
+ if self.is_url(resource):
61
+ # For URLs, we need to fetch and encode
62
+ import requests
63
+
64
+ response = requests.get(resource)
65
+ if response.status_code == 200:
66
+ base64_image = base64.b64encode(response.content).decode("utf-8")
67
+ else:
68
+ logger.error(
69
+ f"Failed to fetch image from URL {resource}: {response.status_code}"
70
+ )
71
+ continue
72
+ else:
73
+ # For local files, read and encode
74
+ with open(resource, "rb") as image_file:
75
+ base64_image = base64.b64encode(image_file.read()).decode("utf-8")
76
+
77
+ # Detect media type from file extension
78
+ from .utils import detect_image_mime_type
79
+
80
+ media_type = detect_image_mime_type(resource)
81
+
82
+ content.append(
83
+ {
84
+ "type": "image",
85
+ "source": {
86
+ "type": "base64",
87
+ "media_type": media_type,
88
+ "data": base64_image,
89
+ },
90
+ }
91
+ )
92
+ except Exception as e:
93
+ logger.error(f"Error processing image {resource}: {e}")
94
+
95
+ return content
96
+
97
+ def _do_prompt(
98
+ self,
99
+ model: str,
100
+ prompt: str,
101
+ images: List[str],
102
+ system_prompt: str,
103
+ response_format: Optional[Any],
104
+ **kwargs,
105
+ ) -> LLMResponse:
106
+ """
107
+ Send a prompt to the Claude model and get the response.
108
+
109
+ Args:
110
+ model: The Claude model identifier (e.g., "claude-3-opus-20240229")
111
+ prompt: The text prompt to send
112
+ images: List of image paths/URLs
113
+ system_prompt: System prompt to use
114
+ response_format: Optional Pydantic model for structured output
115
+ **kwargs: Additional Claude-specific parameters
116
+
117
+ Returns:
118
+ LLMResponse object with the provider's response
119
+ """
120
+ # Prepare content with images
121
+ content = self._prepare_content_with_images(prompt, images)
122
+
123
+ # Determine max_tokens based on model
124
+ if "opus" in model.lower():
125
+ default_max_tokens = 4096
126
+ elif "sonnet" in model.lower():
127
+ default_max_tokens = 8192
128
+ else:
129
+ default_max_tokens = 4096
130
+
131
+ # Extract Claude-specific parameters
132
+ params = {
133
+ "model": model,
134
+ "messages": [{"role": "user", "content": content}],
135
+ "system": system_prompt,
136
+ "max_tokens": kwargs.get(
137
+ "max_tokens", self.settings.get("max_tokens", default_max_tokens)
138
+ ),
139
+ "timeout": 300.0,
140
+ }
141
+
142
+ # Add optional parameters if specified
143
+ optional_params = ["temperature", "top_p", "top_k"]
144
+ for param in optional_params:
145
+ value = kwargs.get(param, self.settings.get(param))
146
+ if value is not None:
147
+ params[param] = value
148
+
149
+ # Handle structured output using tools
150
+ if response_format and hasattr(response_format, "model_json_schema"):
151
+ json_schema = response_format.model_json_schema()
152
+
153
+ tools = [
154
+ {
155
+ "name": "extract_structured_data",
156
+ "description": "Extract structured data according to the provided schema",
157
+ "input_schema": json_schema,
158
+ }
159
+ ]
160
+
161
+ params["tools"] = tools
162
+ params["tool_choice"] = {"type": "tool", "name": "extract_structured_data"}
163
+
164
+ try:
165
+ raw_response = self.api_client.messages.create(**params)
166
+ return self._create_response_from_tool(raw_response, model, response_format)
167
+ except Exception as e:
168
+ logger.warning(
169
+ f"Structured output via tools failed: {e}. Falling back to text mode."
170
+ )
171
+ # Remove tools and try again
172
+ del params["tools"]
173
+ del params["tool_choice"]
174
+
175
+ # Send the request to Anthropic
176
+ raw_response = self.api_client.messages.create(**params)
177
+
178
+ return self._create_response_from_raw(raw_response, model)
179
+
180
+ def _create_response_from_tool(
181
+ self, raw_response: Any, model: str, response_format: Any
182
+ ) -> LLMResponse:
183
+ """
184
+ Create LLMResponse from Claude tool-based response (structured output).
185
+
186
+ Args:
187
+ raw_response: Raw Anthropic response object
188
+ model: Model identifier
189
+ response_format: Pydantic model for validation
190
+
191
+ Returns:
192
+ LLMResponse object
193
+ """
194
+ # Extract tool use from response
195
+ text = ""
196
+ for block in raw_response.content:
197
+ if block.type == "tool_use" and block.name == "extract_structured_data":
198
+ try:
199
+ # Validate with Pydantic and convert to JSON
200
+ structured = response_format(**block.input)
201
+ text = structured.model_dump_json()
202
+ except Exception as e:
203
+ logger.warning(f"Pydantic validation failed: {e}")
204
+ # Use raw tool input without validation
205
+ text = json.dumps(block.input)
206
+ break
207
+ elif block.type == "text":
208
+ text = block.text
209
+
210
+ usage = Usage()
211
+ if hasattr(raw_response, "usage") and raw_response.usage:
212
+ usage = Usage(
213
+ input_tokens=raw_response.usage.input_tokens,
214
+ output_tokens=raw_response.usage.output_tokens,
215
+ total_tokens=raw_response.usage.input_tokens + raw_response.usage.output_tokens,
216
+ )
217
+
218
+ return LLMResponse(
219
+ text=text,
220
+ model=model,
221
+ provider=self.PROVIDER_ID,
222
+ finish_reason=raw_response.stop_reason or "unknown",
223
+ usage=usage,
224
+ raw_response=raw_response,
225
+ )
226
+
227
+ def _create_response_from_raw(self, raw_response: Any, model: str) -> LLMResponse:
228
+ """
229
+ Create LLMResponse from Claude raw response.
230
+
231
+ Args:
232
+ raw_response: Raw Anthropic response object
233
+ model: Model identifier
234
+
235
+ Returns:
236
+ LLMResponse object
237
+ """
238
+ # Extract text from content blocks
239
+ text = ""
240
+ if raw_response.content:
241
+ for block in raw_response.content:
242
+ if block.type == "text":
243
+ text = block.text
244
+ break
245
+
246
+ usage = Usage()
247
+ if hasattr(raw_response, "usage") and raw_response.usage:
248
+ usage = Usage(
249
+ input_tokens=raw_response.usage.input_tokens,
250
+ output_tokens=raw_response.usage.output_tokens,
251
+ total_tokens=raw_response.usage.input_tokens + raw_response.usage.output_tokens,
252
+ )
253
+
254
+ return LLMResponse(
255
+ text=text,
256
+ model=model,
257
+ provider=self.PROVIDER_ID,
258
+ finish_reason=raw_response.stop_reason or "unknown",
259
+ usage=usage,
260
+ raw_response=raw_response,
261
+ )
262
+
263
+ def get_model_list(self) -> List[Tuple[str, Optional[str]]]:
264
+ """
265
+ Get a list of available models from Claude.
266
+
267
+ Returns:
268
+ List of tuples (model_id, created_date)
269
+ """
270
+ if self.api_client is None:
271
+ raise ValueError("Claude client is not initialized.")
272
+
273
+ model_list = []
274
+ raw_list = self.api_client.models.list()
275
+
276
+ for model in raw_list:
277
+ try:
278
+ readable_date = datetime.fromisoformat(str(model.created_at)).strftime("%Y-%m-%d")
279
+ except:
280
+ readable_date = None
281
+ model_list.append((model.id, readable_date))
282
+
283
+ return model_list