askai-python 0.3.3__py3-none-any.whl → 0.4.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.
- ask_ai/__init__.py +27 -2
- ask_ai/base.py +304 -86
- ask_ai/cli.py +83 -53
- ask_ai/config.py +45 -20
- ask_ai/exceptions.py +13 -1
- ask_ai/media.py +53 -26
- ask_ai/providers/__init__.py +1 -4
- ask_ai/providers/anthropic_provider.py +96 -55
- ask_ai/providers/azure_provider.py +39 -18
- ask_ai/providers/google_provider.py +114 -51
- ask_ai/providers/groq_provider.py +87 -48
- ask_ai/providers/openai_provider.py +197 -62
- ask_ai/providers/openrouter_provider.py +95 -16
- ask_ai/utils.py +40 -15
- askai_python-0.4.1.dist-info/METADATA +229 -0
- askai_python-0.4.1.dist-info/RECORD +22 -0
- {askai_python-0.3.3.dist-info → askai_python-0.4.1.dist-info}/WHEEL +1 -1
- askai_python-0.4.1.dist-info/entry_points.txt +2 -0
- askai_python-0.3.3.dist-info/METADATA +0 -145
- askai_python-0.3.3.dist-info/RECORD +0 -21
- {askai_python-0.3.3.dist-info → askai_python-0.4.1.dist-info}/licenses/LICENSE +0 -0
- {askai_python-0.3.3.dist-info → askai_python-0.4.1.dist-info}/top_level.txt +0 -0
ask_ai/__init__.py
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ask-ai: A minimal Python SDK to switch between LLM providers in one line.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
__version__ = "0.4.1"
|
|
6
|
+
|
|
1
7
|
from .providers import (
|
|
2
8
|
OpenAI,
|
|
3
9
|
Anthropic,
|
|
@@ -9,18 +15,37 @@ from .providers import (
|
|
|
9
15
|
from .config import AdvancedConfig
|
|
10
16
|
from .base import Response
|
|
11
17
|
from .media import ImageObject, AudioObject
|
|
12
|
-
from .exceptions import
|
|
18
|
+
from .exceptions import (
|
|
19
|
+
AskAIError,
|
|
20
|
+
AskAIConfigError,
|
|
21
|
+
AskAINetworkError,
|
|
22
|
+
AskAIRateLimitError,
|
|
23
|
+
AskAIParsingError,
|
|
24
|
+
ProviderError,
|
|
25
|
+
MediaTypeNotSupportedError,
|
|
26
|
+
StreamNotSupportedError,
|
|
27
|
+
)
|
|
13
28
|
|
|
14
29
|
__all__ = [
|
|
30
|
+
# Providers
|
|
15
31
|
"OpenAI",
|
|
16
32
|
"Anthropic",
|
|
17
33
|
"Google",
|
|
18
34
|
"Groq",
|
|
19
35
|
"Azure",
|
|
20
36
|
"OpenRouter",
|
|
37
|
+
# Core
|
|
21
38
|
"AdvancedConfig",
|
|
22
39
|
"Response",
|
|
23
40
|
"ImageObject",
|
|
24
41
|
"AudioObject",
|
|
25
|
-
|
|
42
|
+
# Exceptions
|
|
43
|
+
"AskAIError",
|
|
44
|
+
"AskAIConfigError",
|
|
45
|
+
"AskAINetworkError",
|
|
46
|
+
"AskAIRateLimitError",
|
|
47
|
+
"AskAIParsingError",
|
|
48
|
+
"ProviderError",
|
|
49
|
+
"MediaTypeNotSupportedError",
|
|
50
|
+
"StreamNotSupportedError",
|
|
26
51
|
]
|
ask_ai/base.py
CHANGED
|
@@ -1,17 +1,24 @@
|
|
|
1
|
-
from typing import Optional, Union, List, Dict, Any
|
|
2
1
|
import os
|
|
2
|
+
import time
|
|
3
|
+
import random
|
|
4
|
+
import asyncio
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Union, List, Dict, Any, Iterator, AsyncIterator
|
|
7
|
+
|
|
3
8
|
from .config import AdvancedConfig
|
|
4
9
|
from .media import ImageObject, AudioObject
|
|
5
10
|
|
|
11
|
+
logger = logging.getLogger("ask_ai")
|
|
6
12
|
|
|
7
13
|
|
|
8
14
|
class Response:
|
|
9
15
|
"""
|
|
10
|
-
Unified response object for all
|
|
16
|
+
Unified response object for all ask-ai requests.
|
|
11
17
|
"""
|
|
12
|
-
def __init__(self, text: str = "", media: Union[ImageObject, AudioObject, None] = None, **kwargs):
|
|
18
|
+
def __init__(self, text: str = "", media: Union[ImageObject, AudioObject, None] = None, response_model: Any = None, **kwargs):
|
|
13
19
|
self._raw_text = text
|
|
14
20
|
self.media = media
|
|
21
|
+
self.response_model = response_model
|
|
15
22
|
self._kwargs = kwargs
|
|
16
23
|
|
|
17
24
|
@property
|
|
@@ -34,17 +41,40 @@ class Response:
|
|
|
34
41
|
from .utils import parse_json
|
|
35
42
|
return parse_json(self._raw_text)
|
|
36
43
|
|
|
37
|
-
|
|
44
|
+
@property
|
|
45
|
+
def pydantic(self) -> Any:
|
|
46
|
+
"""Returns parsed Pydantic model instance if response_model was provided."""
|
|
47
|
+
if not self.response_model:
|
|
48
|
+
raise ValueError("No response_model was specified for this request.")
|
|
49
|
+
return self.to_model(self.response_model)
|
|
50
|
+
|
|
51
|
+
def to_model(self, model_class: Any) -> Any:
|
|
52
|
+
"""Parse the response text into a specified Pydantic model."""
|
|
53
|
+
text_content = self.text
|
|
54
|
+
if hasattr(model_class, "model_validate_json"):
|
|
55
|
+
return model_class.model_validate_json(text_content)
|
|
56
|
+
elif hasattr(model_class, "parse_raw"):
|
|
57
|
+
return model_class.parse_raw(text_content)
|
|
58
|
+
else:
|
|
59
|
+
import json
|
|
60
|
+
data = json.loads(text_content)
|
|
61
|
+
return model_class(**data)
|
|
62
|
+
|
|
63
|
+
def __str__(self) -> str:
|
|
38
64
|
return self.text
|
|
39
65
|
|
|
40
|
-
def
|
|
66
|
+
def __repr__(self) -> str:
|
|
67
|
+
preview = self._raw_text[:80] + "..." if len(self._raw_text) > 80 else self._raw_text
|
|
68
|
+
return f'<Response text="{preview}">'
|
|
69
|
+
|
|
70
|
+
def save(self, path: str) -> None:
|
|
41
71
|
"""Smart save based on content type."""
|
|
42
72
|
if self.media:
|
|
43
73
|
self.media.save(path)
|
|
44
74
|
else:
|
|
45
75
|
with open(path, "w", encoding="utf-8") as f:
|
|
46
76
|
f.write(self.text)
|
|
47
|
-
|
|
77
|
+
|
|
48
78
|
|
|
49
79
|
class BaseProvider:
|
|
50
80
|
"""
|
|
@@ -52,17 +82,10 @@ class BaseProvider:
|
|
|
52
82
|
Implements the core 'ask' logic and configuration management.
|
|
53
83
|
"""
|
|
54
84
|
def __init__(self, api_key: str = None, model: str = None, persona: str = None, **kwargs):
|
|
55
|
-
#
|
|
85
|
+
# Zero-Config: Try env var if key not provided
|
|
56
86
|
self.api_key = api_key or os.environ.get(self._get_api_key_env_var())
|
|
57
|
-
if not self.api_key:
|
|
58
|
-
# Some providers might not need it (e.g. local), but generally they do.
|
|
59
|
-
# We allow subclass to handle granular validation, but warn here.
|
|
60
|
-
pass
|
|
61
|
-
|
|
62
87
|
self.model = model or self._get_default_model()
|
|
63
88
|
self.persona = persona
|
|
64
|
-
|
|
65
|
-
# Global Advanced Config
|
|
66
89
|
self.config = AdvancedConfig(**kwargs)
|
|
67
90
|
|
|
68
91
|
def _get_api_key_env_var(self) -> str:
|
|
@@ -73,127 +96,320 @@ class BaseProvider:
|
|
|
73
96
|
"""Subclasses should return a default model"""
|
|
74
97
|
raise NotImplementedError
|
|
75
98
|
|
|
76
|
-
def advanced(self, **kwargs):
|
|
99
|
+
def advanced(self, **kwargs) -> None:
|
|
77
100
|
"""
|
|
78
101
|
Update global advanced settings for this instance.
|
|
79
102
|
Merges new settings with existing ones.
|
|
80
103
|
"""
|
|
81
104
|
new_conf = AdvancedConfig(**kwargs)
|
|
82
|
-
# primitive merge: update self.config with new values
|
|
83
105
|
for k, v in new_conf.__dict__.items():
|
|
106
|
+
if k == 'extra':
|
|
107
|
+
continue
|
|
84
108
|
if v is not None:
|
|
85
109
|
setattr(self.config, k, v)
|
|
86
|
-
|
|
87
|
-
# Handle extra kwargs
|
|
88
110
|
if new_conf.extra:
|
|
89
111
|
self.config.extra.update(new_conf.extra)
|
|
90
112
|
|
|
113
|
+
# ─── Sync API ───────────────────────────────────────────
|
|
114
|
+
|
|
91
115
|
def ask(self, query: str, **kwargs) -> Response:
|
|
92
116
|
"""
|
|
93
|
-
The main entry point.
|
|
117
|
+
The main entry point. Sync version.
|
|
94
118
|
Detects intent, manages config, and returns a unified Response.
|
|
95
119
|
"""
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
120
|
+
providers = kwargs.pop("providers", None) or kwargs.pop("fallback", None)
|
|
121
|
+
if providers:
|
|
122
|
+
if not isinstance(providers, (list, tuple)):
|
|
123
|
+
providers = [providers]
|
|
124
|
+
errors = []
|
|
125
|
+
for p in providers:
|
|
126
|
+
if isinstance(p, type):
|
|
127
|
+
try:
|
|
128
|
+
p_inst = p()
|
|
129
|
+
except Exception as e:
|
|
130
|
+
errors.append(f"{p.__name__} init failed: {e}")
|
|
131
|
+
continue
|
|
132
|
+
else:
|
|
133
|
+
p_inst = p
|
|
134
|
+
try:
|
|
135
|
+
return p_inst.ask(query, **kwargs)
|
|
136
|
+
except Exception as e:
|
|
137
|
+
errors.append(f"{p_inst.__class__.__name__}: {e}")
|
|
138
|
+
logger.warning("Provider fallback failed for %s: %s", p_inst.__class__.__name__, e)
|
|
139
|
+
from .exceptions import ProviderError
|
|
140
|
+
raise ProviderError(f"All fallback providers failed: {errors}")
|
|
141
|
+
|
|
142
|
+
final_config = self._build_final_config(kwargs)
|
|
143
|
+
messages = self._prepare_messages(query, final_config)
|
|
144
|
+
output_type = kwargs.get('output_type')
|
|
145
|
+
retry = int(kwargs.get('retry', final_config.extra.get('retry', 0)) or 0)
|
|
146
|
+
response_model = kwargs.get('response_model')
|
|
147
|
+
|
|
148
|
+
response = self._execute_with_retry(
|
|
149
|
+
self._send_request, messages, final_config, output_type, retry
|
|
150
|
+
)
|
|
151
|
+
if response_model:
|
|
152
|
+
response.response_model = response_model
|
|
153
|
+
return response
|
|
154
|
+
|
|
155
|
+
def ask_stream(self, query: str, **kwargs) -> Iterator[str]:
|
|
156
|
+
"""
|
|
157
|
+
Streaming text generation. Yields text chunks.
|
|
158
|
+
"""
|
|
159
|
+
providers = kwargs.pop("providers", None) or kwargs.pop("fallback", None)
|
|
160
|
+
if providers:
|
|
161
|
+
if not isinstance(providers, (list, tuple)):
|
|
162
|
+
providers = [providers]
|
|
163
|
+
errors = []
|
|
164
|
+
for p in providers:
|
|
165
|
+
if isinstance(p, type):
|
|
166
|
+
try:
|
|
167
|
+
p_inst = p()
|
|
168
|
+
except Exception as e:
|
|
169
|
+
errors.append(f"{p.__name__} init failed: {e}")
|
|
170
|
+
continue
|
|
171
|
+
else:
|
|
172
|
+
p_inst = p
|
|
173
|
+
try:
|
|
174
|
+
yield from p_inst.ask_stream(query, **kwargs)
|
|
175
|
+
return
|
|
176
|
+
except Exception as e:
|
|
177
|
+
errors.append(f"{p_inst.__class__.__name__}: {e}")
|
|
178
|
+
logger.warning("Provider fallback failed for %s: %s", p_inst.__class__.__name__, e)
|
|
179
|
+
from .exceptions import ProviderError
|
|
180
|
+
raise ProviderError(f"All fallback providers failed: {errors}")
|
|
99
181
|
|
|
100
|
-
|
|
182
|
+
final_config = self._build_final_config(kwargs)
|
|
101
183
|
messages = self._prepare_messages(query, final_config)
|
|
184
|
+
yield from self._send_request_stream(messages, final_config)
|
|
185
|
+
|
|
186
|
+
# ─── Async API ──────────────────────────────────────────
|
|
102
187
|
|
|
103
|
-
|
|
188
|
+
async def ask_async(self, query: str, **kwargs) -> Response:
|
|
189
|
+
"""
|
|
190
|
+
Native async version of ask().
|
|
191
|
+
Uses provider's async client directly — no thread pool wrapping.
|
|
192
|
+
"""
|
|
193
|
+
providers = kwargs.pop("providers", None) or kwargs.pop("fallback", None)
|
|
194
|
+
if providers:
|
|
195
|
+
if not isinstance(providers, (list, tuple)):
|
|
196
|
+
providers = [providers]
|
|
197
|
+
errors = []
|
|
198
|
+
for p in providers:
|
|
199
|
+
if isinstance(p, type):
|
|
200
|
+
try:
|
|
201
|
+
p_inst = p()
|
|
202
|
+
except Exception as e:
|
|
203
|
+
errors.append(f"{p.__name__} init failed: {e}")
|
|
204
|
+
continue
|
|
205
|
+
else:
|
|
206
|
+
p_inst = p
|
|
207
|
+
try:
|
|
208
|
+
return await p_inst.ask_async(query, **kwargs)
|
|
209
|
+
except Exception as e:
|
|
210
|
+
errors.append(f"{p_inst.__class__.__name__}: {e}")
|
|
211
|
+
logger.warning("Provider fallback failed for %s: %s", p_inst.__class__.__name__, e)
|
|
212
|
+
from .exceptions import ProviderError
|
|
213
|
+
raise ProviderError(f"All fallback providers failed: {errors}")
|
|
214
|
+
|
|
215
|
+
final_config = self._build_final_config(kwargs)
|
|
216
|
+
messages = self._prepare_messages(query, final_config)
|
|
104
217
|
output_type = kwargs.get('output_type')
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
218
|
+
retry = int(kwargs.get('retry', final_config.extra.get('retry', 0)) or 0)
|
|
219
|
+
response_model = kwargs.get('response_model')
|
|
220
|
+
|
|
221
|
+
response = await self._execute_with_retry_async(
|
|
222
|
+
self._send_request_async, messages, final_config, output_type, retry
|
|
223
|
+
)
|
|
224
|
+
if response_model:
|
|
225
|
+
response.response_model = response_model
|
|
226
|
+
return response
|
|
227
|
+
|
|
228
|
+
async def ask_stream_async(self, query: str, **kwargs) -> AsyncIterator[str]:
|
|
229
|
+
"""
|
|
230
|
+
Async streaming text generation. Yields text chunks.
|
|
231
|
+
"""
|
|
232
|
+
providers = kwargs.pop("providers", None) or kwargs.pop("fallback", None)
|
|
233
|
+
if providers:
|
|
234
|
+
if not isinstance(providers, (list, tuple)):
|
|
235
|
+
providers = [providers]
|
|
236
|
+
errors = []
|
|
237
|
+
for p in providers:
|
|
238
|
+
if isinstance(p, type):
|
|
239
|
+
try:
|
|
240
|
+
p_inst = p()
|
|
241
|
+
except Exception as e:
|
|
242
|
+
errors.append(f"{p.__name__} init failed: {e}")
|
|
243
|
+
continue
|
|
244
|
+
else:
|
|
245
|
+
p_inst = p
|
|
246
|
+
try:
|
|
247
|
+
async for chunk in await p_inst.ask_stream_async(query, **kwargs):
|
|
248
|
+
yield chunk
|
|
249
|
+
return
|
|
250
|
+
except Exception as e:
|
|
251
|
+
errors.append(f"{p_inst.__class__.__name__}: {e}")
|
|
252
|
+
logger.warning("Provider fallback failed for %s: %s", p_inst.__class__.__name__, e)
|
|
253
|
+
from .exceptions import ProviderError
|
|
254
|
+
raise ProviderError(f"All fallback providers failed: {errors}")
|
|
255
|
+
|
|
256
|
+
final_config = self._build_final_config(kwargs)
|
|
257
|
+
messages = self._prepare_messages(query, final_config)
|
|
258
|
+
async for chunk in self._send_request_stream_async(messages, final_config):
|
|
259
|
+
yield chunk
|
|
260
|
+
|
|
261
|
+
# ─── Config Helpers ─────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
def _build_final_config(self, kwargs: Dict[str, Any]) -> AdvancedConfig:
|
|
264
|
+
"""Build final config by merging global config with per-request kwargs."""
|
|
265
|
+
request_config = AdvancedConfig(**kwargs)
|
|
266
|
+
final_config = self.config.merge(request_config)
|
|
267
|
+
|
|
268
|
+
# Ensure timeout has a default
|
|
269
|
+
timeout = kwargs.get('timeout', final_config.extra.get('timeout', 30))
|
|
270
|
+
final_config.extra['timeout'] = float(timeout) if timeout is not None else 30.0
|
|
271
|
+
|
|
272
|
+
return final_config
|
|
273
|
+
|
|
274
|
+
def _prepare_messages(self, query: str, config: AdvancedConfig) -> List[Dict[str, str]]:
|
|
275
|
+
"""Build the messages list from query, persona, and config."""
|
|
276
|
+
messages = []
|
|
277
|
+
|
|
278
|
+
system_msg = config.system_message or self.persona or ""
|
|
279
|
+
|
|
280
|
+
# Smart capability: if json=True or response_model is specified
|
|
281
|
+
response_model = config.extra.get('response_model')
|
|
282
|
+
if response_model:
|
|
283
|
+
if hasattr(response_model, "model_json_schema"):
|
|
284
|
+
schema = response_model.model_json_schema()
|
|
285
|
+
elif hasattr(response_model, "schema"):
|
|
286
|
+
schema = response_model.schema()
|
|
287
|
+
else:
|
|
288
|
+
schema = None
|
|
289
|
+
|
|
290
|
+
if schema:
|
|
291
|
+
import json
|
|
292
|
+
schema_str = json.dumps(schema)
|
|
293
|
+
json_instruction = (
|
|
294
|
+
f"You must respond with valid JSON matching the following JSON schema:\n"
|
|
295
|
+
f"{schema_str}\n"
|
|
296
|
+
f"Do not include any markdown markup, explanation, or HTML tags."
|
|
297
|
+
)
|
|
298
|
+
else:
|
|
299
|
+
json_instruction = "You must respond with valid JSON only."
|
|
300
|
+
|
|
301
|
+
system_msg = f"{system_msg}\n{json_instruction}".strip()
|
|
302
|
+
config.extra['json'] = True
|
|
303
|
+
|
|
304
|
+
elif config.extra.get('json'):
|
|
305
|
+
json_instruction = "You must respond with valid JSON only, without any markdown formatting or tags."
|
|
306
|
+
system_msg = f"{system_msg}\n{json_instruction}".strip()
|
|
307
|
+
|
|
308
|
+
if system_msg:
|
|
309
|
+
messages.append({"role": "system", "content": system_msg})
|
|
310
|
+
|
|
311
|
+
messages.append({"role": "user", "content": query})
|
|
312
|
+
return messages
|
|
313
|
+
|
|
314
|
+
# ─── Retry Logic ────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
def _execute_with_retry(self, func, messages, config, output_type, retry):
|
|
317
|
+
"""Execute a sync function with exponential backoff + jitter."""
|
|
116
318
|
attempts = 0
|
|
117
319
|
last_error = None
|
|
320
|
+
|
|
118
321
|
while attempts <= retry:
|
|
119
322
|
try:
|
|
120
|
-
response =
|
|
121
|
-
# Attach parsing arguments to the response object dynamically
|
|
122
|
-
response._kwargs.update(kwargs)
|
|
323
|
+
response = func(messages, config, output_type)
|
|
123
324
|
return response
|
|
124
325
|
except Exception as e:
|
|
125
326
|
last_error = e
|
|
126
327
|
if self._is_retryable_error(e) and attempts < retry:
|
|
127
328
|
attempts += 1
|
|
128
|
-
delay = 2 ** attempts
|
|
129
|
-
|
|
130
|
-
|
|
329
|
+
delay = min(2 ** attempts + random.uniform(0, 1), 30)
|
|
330
|
+
logger.warning(
|
|
331
|
+
"Transient error (%s). Retrying in %.1fs... (%d/%d)",
|
|
332
|
+
e.__class__.__name__, delay, attempts, retry
|
|
333
|
+
)
|
|
131
334
|
time.sleep(delay)
|
|
132
335
|
else:
|
|
133
|
-
raise
|
|
134
|
-
|
|
135
|
-
# Fallback if loop exits without returning
|
|
336
|
+
raise
|
|
337
|
+
|
|
136
338
|
from .exceptions import AskAINetworkError
|
|
137
339
|
raise AskAINetworkError(f"Request failed after {retry} retries. Last error: {last_error}")
|
|
138
340
|
|
|
139
|
-
async def
|
|
140
|
-
"""
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
341
|
+
async def _execute_with_retry_async(self, func, messages, config, output_type, retry):
|
|
342
|
+
"""Execute an async function with exponential backoff + jitter."""
|
|
343
|
+
attempts = 0
|
|
344
|
+
last_error = None
|
|
345
|
+
|
|
346
|
+
while attempts <= retry:
|
|
347
|
+
try:
|
|
348
|
+
response = await func(messages, config, output_type)
|
|
349
|
+
return response
|
|
350
|
+
except Exception as e:
|
|
351
|
+
last_error = e
|
|
352
|
+
if self._is_retryable_error(e) and attempts < retry:
|
|
353
|
+
attempts += 1
|
|
354
|
+
delay = min(2 ** attempts + random.uniform(0, 1), 30)
|
|
355
|
+
logger.warning(
|
|
356
|
+
"Transient error (%s). Retrying in %.1fs... (%d/%d)",
|
|
357
|
+
e.__class__.__name__, delay, attempts, retry
|
|
358
|
+
)
|
|
359
|
+
await asyncio.sleep(delay)
|
|
360
|
+
else:
|
|
361
|
+
raise
|
|
362
|
+
|
|
363
|
+
from .exceptions import AskAINetworkError
|
|
364
|
+
raise AskAINetworkError(f"Request failed after {retry} retries. Last error: {last_error}")
|
|
149
365
|
|
|
150
366
|
def _is_retryable_error(self, e: Exception) -> bool:
|
|
151
367
|
"""Dynamically detect rate limits and network timeouts across different provider SDKs."""
|
|
368
|
+
from .exceptions import AskAINetworkError, AskAIRateLimitError
|
|
369
|
+
if isinstance(e, (AskAINetworkError, AskAIRateLimitError)):
|
|
370
|
+
return True
|
|
371
|
+
|
|
152
372
|
err_str = str(e).lower()
|
|
153
373
|
err_cls = e.__class__.__name__.lower()
|
|
154
|
-
|
|
155
|
-
# Explicit ask-ai errors
|
|
156
|
-
from .exceptions import AskAINetworkError, AskAIRateLimitError
|
|
157
|
-
if isinstance(e, (AskAINetworkError, AskAIRateLimitError)): return True
|
|
158
|
-
|
|
159
|
-
# Heuristics for underlying provider SDKs (OpenAI, Anthropic, etc.)
|
|
160
|
-
if "ratelimit" in err_cls or "rate_limit" in err_cls or "429" in err_str: return True
|
|
161
|
-
if "timeout" in err_cls or "timeout" in err_str: return True
|
|
162
|
-
if "connection" in err_cls or "connecterror" in err_cls: return True
|
|
163
|
-
|
|
164
|
-
return False
|
|
165
|
-
|
|
166
|
-
def _merge_configs(self, global_conf: AdvancedConfig, req_conf: AdvancedConfig) -> AdvancedConfig:
|
|
167
|
-
return global_conf.merge(req_conf) # We need to implement merge logic in AdvancedConfig properly or here
|
|
168
374
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
system_msg = config.system_message or self.persona or ""
|
|
173
|
-
|
|
174
|
-
# Smart capability: if json=True, enforce JSON system prompt
|
|
175
|
-
if config.extra.get('json'):
|
|
176
|
-
json_instruction = "You must respond with valid JSON only, without any markdown formatting or tags."
|
|
177
|
-
system_msg = f"{system_msg}\n{json_instruction}".strip()
|
|
375
|
+
retryable_indicators = ("ratelimit", "rate_limit", "429", "timeout", "connection", "connecterror")
|
|
376
|
+
return any(indicator in err_cls or indicator in err_str for indicator in retryable_indicators)
|
|
178
377
|
|
|
179
|
-
|
|
180
|
-
messages.append({"role": "system", "content": system_msg})
|
|
181
|
-
|
|
182
|
-
messages.append({"role": "user", "content": query})
|
|
183
|
-
return messages
|
|
378
|
+
# ─── Abstract Methods (Subclasses Must Implement) ──────
|
|
184
379
|
|
|
185
380
|
def _send_request(self, messages: List[Dict[str, str]], config: AdvancedConfig, output_type: str = None) -> Response:
|
|
186
|
-
"""
|
|
381
|
+
"""Sync request — subclasses must implement."""
|
|
187
382
|
raise NotImplementedError
|
|
188
383
|
|
|
189
|
-
|
|
384
|
+
async def _send_request_async(self, messages: List[Dict[str, str]], config: AdvancedConfig, output_type: str = None) -> Response:
|
|
385
|
+
"""Async request — subclasses should override for native async."""
|
|
386
|
+
# Default fallback: run sync in thread pool
|
|
387
|
+
loop = asyncio.get_running_loop()
|
|
388
|
+
return await loop.run_in_executor(None, self._send_request, messages, config, output_type)
|
|
389
|
+
|
|
390
|
+
def _send_request_stream(self, messages: List[Dict[str, str]], config: AdvancedConfig) -> Iterator[str]:
|
|
391
|
+
"""Sync streaming — subclasses should override."""
|
|
392
|
+
from .exceptions import StreamNotSupportedError
|
|
393
|
+
raise StreamNotSupportedError(f"{self.__class__.__name__} does not support streaming.")
|
|
394
|
+
|
|
395
|
+
async def _send_request_stream_async(self, messages: List[Dict[str, str]], config: AdvancedConfig) -> AsyncIterator[str]:
|
|
396
|
+
"""Async streaming — subclasses should override for native async."""
|
|
397
|
+
# Default fallback: wrap sync stream
|
|
398
|
+
loop = asyncio.get_running_loop()
|
|
399
|
+
for chunk in await loop.run_in_executor(None, lambda: list(self._send_request_stream(messages, config))):
|
|
400
|
+
yield chunk
|
|
401
|
+
|
|
402
|
+
# ─── Tool Definitions for Smart Intent ─────────────────
|
|
403
|
+
|
|
190
404
|
def _get_media_tools(self) -> List[Dict[str, Any]]:
|
|
191
405
|
return [
|
|
192
406
|
{
|
|
193
407
|
"type": "function",
|
|
194
408
|
"function": {
|
|
195
409
|
"name": "generate_image",
|
|
196
|
-
"description":
|
|
410
|
+
"description": (
|
|
411
|
+
"Generate an image based on a prompt. Use this when the user asks to draw, create, or show an image."
|
|
412
|
+
),
|
|
197
413
|
"parameters": {
|
|
198
414
|
"type": "object",
|
|
199
415
|
"properties": {
|
|
@@ -210,7 +426,9 @@ class BaseProvider:
|
|
|
210
426
|
"type": "function",
|
|
211
427
|
"function": {
|
|
212
428
|
"name": "generate_speech",
|
|
213
|
-
"description":
|
|
429
|
+
"description": (
|
|
430
|
+
"Generate audio speech from text. Use this when the user asks to say something, speak, or read aloud."
|
|
431
|
+
),
|
|
214
432
|
"parameters": {
|
|
215
433
|
"type": "object",
|
|
216
434
|
"properties": {
|