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 +66 -0
- ai_client/base_client.py +353 -0
- ai_client/claude_client.py +283 -0
- ai_client/deepseek_client.py +28 -0
- ai_client/gemini_client.py +269 -0
- ai_client/mistral_client.py +191 -0
- ai_client/openai_client.py +299 -0
- ai_client/qwen_client.py +28 -0
- ai_client/response.py +89 -0
- ai_client/utils.py +151 -0
- generic_llm_api_client-0.1.1.dist-info/METADATA +477 -0
- generic_llm_api_client-0.1.1.dist-info/RECORD +15 -0
- generic_llm_api_client-0.1.1.dist-info/WHEEL +5 -0
- generic_llm_api_client-0.1.1.dist-info/licenses/LICENSE +21 -0
- generic_llm_api_client-0.1.1.dist-info/top_level.txt +1 -0
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
|
+
]
|
ai_client/base_client.py
ADDED
|
@@ -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
|