askai-python 0.4.1__py3-none-any.whl → 0.5.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.
- ask_ai/__init__.py +42 -14
- ask_ai/base.py +298 -36
- ask_ai/capabilities.py +17 -0
- ask_ai/cli.py +5 -19
- ask_ai/config.py +21 -11
- ask_ai/events.py +41 -0
- ask_ai/exceptions.py +71 -0
- ask_ai/media.py +439 -52
- ask_ai/media_security.py +105 -0
- ask_ai/observability.py +53 -0
- ask_ai/providers/__init__.py +4 -4
- ask_ai/providers/anthropic_provider.py +31 -16
- ask_ai/providers/azure_provider.py +4 -2
- ask_ai/providers/google_provider.py +27 -14
- ask_ai/providers/groq_provider.py +22 -14
- ask_ai/providers/openai_provider.py +80 -53
- ask_ai/providers/openrouter_provider.py +31 -12
- ask_ai/requirements.txt +5 -4
- ask_ai/router.py +107 -0
- ask_ai/session.py +114 -0
- ask_ai/structured.py +88 -0
- ask_ai/utils.py +3 -4
- {askai_python-0.4.1.dist-info → askai_python-0.5.0.dist-info}/METADATA +6 -1
- askai_python-0.5.0.dist-info/RECORD +29 -0
- {askai_python-0.4.1.dist-info → askai_python-0.5.0.dist-info}/WHEEL +1 -1
- askai_python-0.4.1.dist-info/RECORD +0 -22
- {askai_python-0.4.1.dist-info → askai_python-0.5.0.dist-info}/entry_points.txt +0 -0
- {askai_python-0.4.1.dist-info → askai_python-0.5.0.dist-info}/licenses/LICENSE +0 -0
- {askai_python-0.4.1.dist-info → askai_python-0.5.0.dist-info}/top_level.txt +0 -0
ask_ai/__init__.py
CHANGED
|
@@ -2,29 +2,36 @@
|
|
|
2
2
|
ask-ai: A minimal Python SDK to switch between LLM providers in one line.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
-
__version__ = "0.
|
|
5
|
+
__version__ = "0.5.0"
|
|
6
6
|
|
|
7
|
-
from .providers import (
|
|
8
|
-
OpenAI,
|
|
9
|
-
Anthropic,
|
|
10
|
-
Google,
|
|
11
|
-
Groq,
|
|
12
|
-
Azure,
|
|
13
|
-
OpenRouter
|
|
14
|
-
)
|
|
15
|
-
from .config import AdvancedConfig
|
|
16
7
|
from .base import Response
|
|
17
|
-
from .
|
|
8
|
+
from .capabilities import Capabilities
|
|
9
|
+
from .config import AdvancedConfig
|
|
10
|
+
from .events import Completed, Error, TextDelta, ToolCall, UsageUpdate
|
|
18
11
|
from .exceptions import (
|
|
19
|
-
AskAIError,
|
|
20
12
|
AskAIConfigError,
|
|
13
|
+
AskAIError,
|
|
21
14
|
AskAINetworkError,
|
|
22
|
-
AskAIRateLimitError,
|
|
23
15
|
AskAIParsingError,
|
|
24
|
-
|
|
16
|
+
AskAIRateLimitError,
|
|
17
|
+
MediaSecurityError,
|
|
25
18
|
MediaTypeNotSupportedError,
|
|
19
|
+
ProviderAuthError,
|
|
20
|
+
ProviderError,
|
|
21
|
+
ProviderInvalidRequestError,
|
|
22
|
+
ProviderRateLimitError,
|
|
23
|
+
ProviderTimeoutError,
|
|
24
|
+
ProviderUnavailableError,
|
|
26
25
|
StreamNotSupportedError,
|
|
26
|
+
StructuredValidationError,
|
|
27
27
|
)
|
|
28
|
+
from .media import AudioObject, ImageObject
|
|
29
|
+
from .media_security import MediaPolicy, download_media
|
|
30
|
+
from .observability import Observability, ResponseMetadata, Usage
|
|
31
|
+
from .providers import Anthropic, Azure, Google, Groq, OpenAI, OpenRouter
|
|
32
|
+
from .router import Router
|
|
33
|
+
from .session import ConversationSession
|
|
34
|
+
from .structured import parse_structured
|
|
28
35
|
|
|
29
36
|
__all__ = [
|
|
30
37
|
# Providers
|
|
@@ -46,6 +53,27 @@ __all__ = [
|
|
|
46
53
|
"AskAIRateLimitError",
|
|
47
54
|
"AskAIParsingError",
|
|
48
55
|
"ProviderError",
|
|
56
|
+
"ProviderAuthError",
|
|
57
|
+
"ProviderRateLimitError",
|
|
58
|
+
"ProviderTimeoutError",
|
|
59
|
+
"ProviderInvalidRequestError",
|
|
60
|
+
"ProviderUnavailableError",
|
|
49
61
|
"MediaTypeNotSupportedError",
|
|
62
|
+
"MediaSecurityError",
|
|
50
63
|
"StreamNotSupportedError",
|
|
64
|
+
"StructuredValidationError",
|
|
65
|
+
"parse_structured",
|
|
66
|
+
"TextDelta",
|
|
67
|
+
"ToolCall",
|
|
68
|
+
"UsageUpdate",
|
|
69
|
+
"Completed",
|
|
70
|
+
"Error",
|
|
71
|
+
"Observability",
|
|
72
|
+
"ResponseMetadata",
|
|
73
|
+
"Usage",
|
|
74
|
+
"Capabilities",
|
|
75
|
+
"Router",
|
|
76
|
+
"ConversationSession",
|
|
77
|
+
"MediaPolicy",
|
|
78
|
+
"download_media",
|
|
51
79
|
]
|
ask_ai/base.py
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
import os
|
|
2
|
-
import time
|
|
3
|
-
import random
|
|
4
1
|
import asyncio
|
|
5
2
|
import logging
|
|
6
|
-
|
|
3
|
+
import os
|
|
4
|
+
import random
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import AsyncIterator, Iterator
|
|
7
|
+
from typing import Any, Optional, Union
|
|
7
8
|
|
|
9
|
+
from .capabilities import Capabilities
|
|
8
10
|
from .config import AdvancedConfig
|
|
9
|
-
from .
|
|
11
|
+
from .events import Completed, Error, TextDelta, ToolCall, UsageUpdate
|
|
12
|
+
from .exceptions import StructuredValidationError
|
|
13
|
+
from .media import AudioObject, ImageObject
|
|
14
|
+
from .observability import Observability, ResponseMetadata, Usage
|
|
10
15
|
|
|
11
16
|
logger = logging.getLogger("ask_ai")
|
|
12
17
|
|
|
@@ -15,10 +20,18 @@ class Response:
|
|
|
15
20
|
"""
|
|
16
21
|
Unified response object for all ask-ai requests.
|
|
17
22
|
"""
|
|
18
|
-
def __init__(
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
text: str = "",
|
|
26
|
+
media: Union[ImageObject, AudioObject, None] = None,
|
|
27
|
+
response_model: Any = None,
|
|
28
|
+
metadata: Any = None,
|
|
29
|
+
**kwargs,
|
|
30
|
+
):
|
|
19
31
|
self._raw_text = text
|
|
20
32
|
self.media = media
|
|
21
33
|
self.response_model = response_model
|
|
34
|
+
self.metadata = metadata
|
|
22
35
|
self._kwargs = kwargs
|
|
23
36
|
|
|
24
37
|
@property
|
|
@@ -36,7 +49,20 @@ class Response:
|
|
|
36
49
|
return text
|
|
37
50
|
|
|
38
51
|
@property
|
|
39
|
-
def
|
|
52
|
+
def raw(self) -> str:
|
|
53
|
+
"""Return the unmodified provider text."""
|
|
54
|
+
return self._raw_text
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def parsed(self) -> Any:
|
|
58
|
+
"""Return the validated structured response, if one was requested."""
|
|
59
|
+
if not self.response_model:
|
|
60
|
+
raise ValueError("No response_model was specified for this request.")
|
|
61
|
+
from .structured import parse_structured
|
|
62
|
+
return parse_structured(self._raw_text, self.response_model)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def json(self) -> Union[dict[str, Any], list]:
|
|
40
66
|
"""Returns parsed JSON. If 'json=True' was passed, this is highly reliable."""
|
|
41
67
|
from .utils import parse_json
|
|
42
68
|
return parse_json(self._raw_text)
|
|
@@ -46,10 +72,13 @@ class Response:
|
|
|
46
72
|
"""Returns parsed Pydantic model instance if response_model was provided."""
|
|
47
73
|
if not self.response_model:
|
|
48
74
|
raise ValueError("No response_model was specified for this request.")
|
|
49
|
-
return self.
|
|
75
|
+
return self.parsed
|
|
50
76
|
|
|
51
77
|
def to_model(self, model_class: Any) -> Any:
|
|
52
78
|
"""Parse the response text into a specified Pydantic model."""
|
|
79
|
+
from .structured import parse_structured
|
|
80
|
+
if isinstance(model_class, dict) or hasattr(model_class, "model_validate") or hasattr(model_class, "parse_obj"):
|
|
81
|
+
return parse_structured(self._raw_text, model_class)
|
|
53
82
|
text_content = self.text
|
|
54
83
|
if hasattr(model_class, "model_validate_json"):
|
|
55
84
|
return model_class.model_validate_json(text_content)
|
|
@@ -87,6 +116,141 @@ class BaseProvider:
|
|
|
87
116
|
self.model = model or self._get_default_model()
|
|
88
117
|
self.persona = persona
|
|
89
118
|
self.config = AdvancedConfig(**kwargs)
|
|
119
|
+
self._middleware: list[Observability] = []
|
|
120
|
+
|
|
121
|
+
def use(self, middleware: Observability) -> "BaseProvider":
|
|
122
|
+
"""Attach optional observability middleware and return this provider."""
|
|
123
|
+
self._middleware.append(middleware)
|
|
124
|
+
return self
|
|
125
|
+
|
|
126
|
+
def session(self, max_messages: Optional[int] = None):
|
|
127
|
+
from .session import ConversationSession
|
|
128
|
+
return ConversationSession(self, max_messages=max_messages)
|
|
129
|
+
|
|
130
|
+
def capabilities(self) -> Capabilities:
|
|
131
|
+
"""Return conservative capabilities for custom providers."""
|
|
132
|
+
return Capabilities()
|
|
133
|
+
|
|
134
|
+
def _attach_metadata(self, response: Response, started_at: float, retry_count: int) -> Response:
|
|
135
|
+
usage = getattr(response, "usage", None)
|
|
136
|
+
if isinstance(usage, dict):
|
|
137
|
+
usage = Usage(**usage)
|
|
138
|
+
metadata = ResponseMetadata(
|
|
139
|
+
provider=self.__class__.__name__,
|
|
140
|
+
model=self.model,
|
|
141
|
+
latency_ms=(time.perf_counter() - started_at) * 1000,
|
|
142
|
+
retry_count=retry_count,
|
|
143
|
+
usage=usage,
|
|
144
|
+
estimated_cost=None,
|
|
145
|
+
)
|
|
146
|
+
response.metadata = metadata
|
|
147
|
+
for middleware in getattr(self, "_middleware", []):
|
|
148
|
+
metadata = ResponseMetadata(
|
|
149
|
+
**{**metadata.__dict__, "estimated_cost": middleware.cost(self.model, usage)}
|
|
150
|
+
)
|
|
151
|
+
response.metadata = metadata
|
|
152
|
+
middleware.emit({"type": "completed", "metadata": metadata, "response": response})
|
|
153
|
+
return response
|
|
154
|
+
|
|
155
|
+
def _attach_failure_metadata(self, error: Exception, started_at: float) -> None:
|
|
156
|
+
metadata = ResponseMetadata(
|
|
157
|
+
provider=self.__class__.__name__,
|
|
158
|
+
model=self.model,
|
|
159
|
+
latency_ms=(time.perf_counter() - started_at) * 1000,
|
|
160
|
+
retry_count=getattr(self, "_retry_count", 0),
|
|
161
|
+
failure_reason=str(error),
|
|
162
|
+
)
|
|
163
|
+
for middleware in getattr(self, "_middleware", []):
|
|
164
|
+
middleware.emit({"type": "failed", "metadata": metadata, "error": error})
|
|
165
|
+
|
|
166
|
+
def _validation_retry_messages(
|
|
167
|
+
self, messages: list[dict[str, str]], response: Response, error: StructuredValidationError
|
|
168
|
+
) -> list[dict[str, str]]:
|
|
169
|
+
return [
|
|
170
|
+
*messages,
|
|
171
|
+
{"role": "assistant", "content": response.raw},
|
|
172
|
+
{
|
|
173
|
+
"role": "user",
|
|
174
|
+
"content": (
|
|
175
|
+
"Your previous response failed schema validation: "
|
|
176
|
+
f"{error}. Return a corrected JSON response only."
|
|
177
|
+
),
|
|
178
|
+
},
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
def _execute_response(
|
|
182
|
+
self,
|
|
183
|
+
messages: list[dict[str, str]],
|
|
184
|
+
config: AdvancedConfig,
|
|
185
|
+
output_type: Optional[str],
|
|
186
|
+
retry: int,
|
|
187
|
+
response_model: Any,
|
|
188
|
+
validation_retries: int,
|
|
189
|
+
started_at: float,
|
|
190
|
+
) -> Response:
|
|
191
|
+
if validation_retries < 0:
|
|
192
|
+
raise ValueError("validation_retries must be non-negative.")
|
|
193
|
+
request_messages = messages
|
|
194
|
+
retry_count = 0
|
|
195
|
+
try:
|
|
196
|
+
for attempt in range(validation_retries + 1):
|
|
197
|
+
response = self._execute_with_retry(
|
|
198
|
+
self._send_request, request_messages, config, output_type, retry
|
|
199
|
+
)
|
|
200
|
+
retry_count += getattr(self, "_retry_count", 0)
|
|
201
|
+
if response_model is None:
|
|
202
|
+
return self._attach_metadata(response, started_at, retry_count)
|
|
203
|
+
response.response_model = response_model
|
|
204
|
+
try:
|
|
205
|
+
response.parsed
|
|
206
|
+
except StructuredValidationError as error:
|
|
207
|
+
if attempt == validation_retries:
|
|
208
|
+
raise
|
|
209
|
+
request_messages = self._validation_retry_messages(messages, response, error)
|
|
210
|
+
else:
|
|
211
|
+
return self._attach_metadata(response, started_at, retry_count)
|
|
212
|
+
raise RuntimeError("Structured validation retry loop exited unexpectedly.")
|
|
213
|
+
except Exception as error:
|
|
214
|
+
self._retry_count = retry_count + getattr(self, "_retry_count", 0)
|
|
215
|
+
self._attach_failure_metadata(error, started_at)
|
|
216
|
+
raise
|
|
217
|
+
|
|
218
|
+
async def _execute_response_async(
|
|
219
|
+
self,
|
|
220
|
+
messages: list[dict[str, str]],
|
|
221
|
+
config: AdvancedConfig,
|
|
222
|
+
output_type: Optional[str],
|
|
223
|
+
retry: int,
|
|
224
|
+
response_model: Any,
|
|
225
|
+
validation_retries: int,
|
|
226
|
+
started_at: float,
|
|
227
|
+
) -> Response:
|
|
228
|
+
if validation_retries < 0:
|
|
229
|
+
raise ValueError("validation_retries must be non-negative.")
|
|
230
|
+
request_messages = messages
|
|
231
|
+
retry_count = 0
|
|
232
|
+
try:
|
|
233
|
+
for attempt in range(validation_retries + 1):
|
|
234
|
+
response = await self._execute_with_retry_async(
|
|
235
|
+
self._send_request_async, request_messages, config, output_type, retry
|
|
236
|
+
)
|
|
237
|
+
retry_count += getattr(self, "_retry_count", 0)
|
|
238
|
+
if response_model is None:
|
|
239
|
+
return self._attach_metadata(response, started_at, retry_count)
|
|
240
|
+
response.response_model = response_model
|
|
241
|
+
try:
|
|
242
|
+
response.parsed
|
|
243
|
+
except StructuredValidationError as error:
|
|
244
|
+
if attempt == validation_retries:
|
|
245
|
+
raise
|
|
246
|
+
request_messages = self._validation_retry_messages(messages, response, error)
|
|
247
|
+
else:
|
|
248
|
+
return self._attach_metadata(response, started_at, retry_count)
|
|
249
|
+
raise RuntimeError("Structured validation retry loop exited unexpectedly.")
|
|
250
|
+
except Exception as error:
|
|
251
|
+
self._retry_count = retry_count + getattr(self, "_retry_count", 0)
|
|
252
|
+
self._attach_failure_metadata(error, started_at)
|
|
253
|
+
raise
|
|
90
254
|
|
|
91
255
|
def _get_api_key_env_var(self) -> str:
|
|
92
256
|
"""Subclasses should return the env var name, e.g. 'OPENAI_API_KEY'"""
|
|
@@ -103,7 +267,9 @@ class BaseProvider:
|
|
|
103
267
|
"""
|
|
104
268
|
new_conf = AdvancedConfig(**kwargs)
|
|
105
269
|
for k, v in new_conf.__dict__.items():
|
|
106
|
-
if k
|
|
270
|
+
if k in ('extra', '_safe_mode_provided'):
|
|
271
|
+
continue
|
|
272
|
+
if k == 'safe_mode' and not new_conf._safe_mode_provided:
|
|
107
273
|
continue
|
|
108
274
|
if v is not None:
|
|
109
275
|
setattr(self.config, k, v)
|
|
@@ -144,13 +310,13 @@ class BaseProvider:
|
|
|
144
310
|
output_type = kwargs.get('output_type')
|
|
145
311
|
retry = int(kwargs.get('retry', final_config.extra.get('retry', 0)) or 0)
|
|
146
312
|
response_model = kwargs.get('response_model')
|
|
313
|
+
validation_retries = int(kwargs.get("validation_retries", 0) or 0)
|
|
147
314
|
|
|
148
|
-
|
|
149
|
-
|
|
315
|
+
started_at = time.perf_counter()
|
|
316
|
+
return self._execute_response(
|
|
317
|
+
messages, final_config, output_type, retry,
|
|
318
|
+
response_model, validation_retries, started_at,
|
|
150
319
|
)
|
|
151
|
-
if response_model:
|
|
152
|
-
response.response_model = response_model
|
|
153
|
-
return response
|
|
154
320
|
|
|
155
321
|
def ask_stream(self, query: str, **kwargs) -> Iterator[str]:
|
|
156
322
|
"""
|
|
@@ -171,9 +337,14 @@ class BaseProvider:
|
|
|
171
337
|
else:
|
|
172
338
|
p_inst = p
|
|
173
339
|
try:
|
|
174
|
-
|
|
340
|
+
emitted = False
|
|
341
|
+
for chunk in p_inst.ask_stream(query, **kwargs):
|
|
342
|
+
emitted = True
|
|
343
|
+
yield chunk
|
|
175
344
|
return
|
|
176
345
|
except Exception as e:
|
|
346
|
+
if emitted:
|
|
347
|
+
raise
|
|
177
348
|
errors.append(f"{p_inst.__class__.__name__}: {e}")
|
|
178
349
|
logger.warning("Provider fallback failed for %s: %s", p_inst.__class__.__name__, e)
|
|
179
350
|
from .exceptions import ProviderError
|
|
@@ -183,6 +354,39 @@ class BaseProvider:
|
|
|
183
354
|
messages = self._prepare_messages(query, final_config)
|
|
184
355
|
yield from self._send_request_stream(messages, final_config)
|
|
185
356
|
|
|
357
|
+
def ask_stream_events(self, query: str, **kwargs) -> Iterator[Any]:
|
|
358
|
+
"""Yield typed events while preserving the legacy text stream API."""
|
|
359
|
+
fallback_providers = kwargs.pop("providers", None) or kwargs.pop("fallback", None)
|
|
360
|
+
if fallback_providers:
|
|
361
|
+
if not isinstance(fallback_providers, (list, tuple)):
|
|
362
|
+
fallback_providers = [fallback_providers]
|
|
363
|
+
for provider in fallback_providers:
|
|
364
|
+
provider_instance = provider() if isinstance(provider, type) else provider
|
|
365
|
+
yield from provider_instance.ask_stream_events(query, **kwargs)
|
|
366
|
+
return
|
|
367
|
+
|
|
368
|
+
emitted_chunks = 0
|
|
369
|
+
text_parts = []
|
|
370
|
+
usage = None
|
|
371
|
+
final_config = self._build_final_config(kwargs)
|
|
372
|
+
messages = self._prepare_messages(query, final_config)
|
|
373
|
+
try:
|
|
374
|
+
for event in self._send_request_stream_events(messages, final_config):
|
|
375
|
+
if isinstance(event, TextDelta):
|
|
376
|
+
emitted_chunks += 1
|
|
377
|
+
text_parts.append(event.text)
|
|
378
|
+
yield event
|
|
379
|
+
elif isinstance(event, UsageUpdate):
|
|
380
|
+
usage = event
|
|
381
|
+
yield event
|
|
382
|
+
elif isinstance(event, ToolCall):
|
|
383
|
+
yield event
|
|
384
|
+
else:
|
|
385
|
+
yield event
|
|
386
|
+
yield Completed(response=Response(text="".join(text_parts)), usage=usage)
|
|
387
|
+
except Exception as error:
|
|
388
|
+
yield Error(error=error, emitted_chunks=emitted_chunks)
|
|
389
|
+
|
|
186
390
|
# ─── Async API ──────────────────────────────────────────
|
|
187
391
|
|
|
188
392
|
async def ask_async(self, query: str, **kwargs) -> Response:
|
|
@@ -217,13 +421,13 @@ class BaseProvider:
|
|
|
217
421
|
output_type = kwargs.get('output_type')
|
|
218
422
|
retry = int(kwargs.get('retry', final_config.extra.get('retry', 0)) or 0)
|
|
219
423
|
response_model = kwargs.get('response_model')
|
|
424
|
+
validation_retries = int(kwargs.get("validation_retries", 0) or 0)
|
|
220
425
|
|
|
221
|
-
|
|
222
|
-
|
|
426
|
+
started_at = time.perf_counter()
|
|
427
|
+
return await self._execute_response_async(
|
|
428
|
+
messages, final_config, output_type, retry,
|
|
429
|
+
response_model, validation_retries, started_at,
|
|
223
430
|
)
|
|
224
|
-
if response_model:
|
|
225
|
-
response.response_model = response_model
|
|
226
|
-
return response
|
|
227
431
|
|
|
228
432
|
async def ask_stream_async(self, query: str, **kwargs) -> AsyncIterator[str]:
|
|
229
433
|
"""
|
|
@@ -244,10 +448,14 @@ class BaseProvider:
|
|
|
244
448
|
else:
|
|
245
449
|
p_inst = p
|
|
246
450
|
try:
|
|
247
|
-
|
|
451
|
+
emitted = False
|
|
452
|
+
async for chunk in p_inst.ask_stream_async(query, **kwargs):
|
|
453
|
+
emitted = True
|
|
248
454
|
yield chunk
|
|
249
455
|
return
|
|
250
456
|
except Exception as e:
|
|
457
|
+
if emitted:
|
|
458
|
+
raise
|
|
251
459
|
errors.append(f"{p_inst.__class__.__name__}: {e}")
|
|
252
460
|
logger.warning("Provider fallback failed for %s: %s", p_inst.__class__.__name__, e)
|
|
253
461
|
from .exceptions import ProviderError
|
|
@@ -258,9 +466,43 @@ class BaseProvider:
|
|
|
258
466
|
async for chunk in self._send_request_stream_async(messages, final_config):
|
|
259
467
|
yield chunk
|
|
260
468
|
|
|
469
|
+
async def ask_stream_events_async(self, query: str, **kwargs) -> AsyncIterator[Any]:
|
|
470
|
+
"""Yield typed events while preserving the legacy async text stream API."""
|
|
471
|
+
fallback_providers = kwargs.pop("providers", None) or kwargs.pop("fallback", None)
|
|
472
|
+
if fallback_providers:
|
|
473
|
+
if not isinstance(fallback_providers, (list, tuple)):
|
|
474
|
+
fallback_providers = [fallback_providers]
|
|
475
|
+
for provider in fallback_providers:
|
|
476
|
+
provider_instance = provider() if isinstance(provider, type) else provider
|
|
477
|
+
async for event in provider_instance.ask_stream_events_async(query, **kwargs):
|
|
478
|
+
yield event
|
|
479
|
+
return
|
|
480
|
+
|
|
481
|
+
emitted_chunks = 0
|
|
482
|
+
text_parts = []
|
|
483
|
+
usage = None
|
|
484
|
+
final_config = self._build_final_config(kwargs)
|
|
485
|
+
messages = self._prepare_messages(query, final_config)
|
|
486
|
+
try:
|
|
487
|
+
async for event in self._send_request_stream_events_async(messages, final_config):
|
|
488
|
+
if isinstance(event, TextDelta):
|
|
489
|
+
emitted_chunks += 1
|
|
490
|
+
text_parts.append(event.text)
|
|
491
|
+
yield event
|
|
492
|
+
elif isinstance(event, UsageUpdate):
|
|
493
|
+
usage = event
|
|
494
|
+
yield event
|
|
495
|
+
elif isinstance(event, ToolCall):
|
|
496
|
+
yield event
|
|
497
|
+
else:
|
|
498
|
+
yield event
|
|
499
|
+
yield Completed(response=Response(text="".join(text_parts)), usage=usage)
|
|
500
|
+
except Exception as error:
|
|
501
|
+
yield Error(error=error, emitted_chunks=emitted_chunks)
|
|
502
|
+
|
|
261
503
|
# ─── Config Helpers ─────────────────────────────────────
|
|
262
504
|
|
|
263
|
-
def _build_final_config(self, kwargs:
|
|
505
|
+
def _build_final_config(self, kwargs: dict[str, Any]) -> AdvancedConfig:
|
|
264
506
|
"""Build final config by merging global config with per-request kwargs."""
|
|
265
507
|
request_config = AdvancedConfig(**kwargs)
|
|
266
508
|
final_config = self.config.merge(request_config)
|
|
@@ -271,7 +513,7 @@ class BaseProvider:
|
|
|
271
513
|
|
|
272
514
|
return final_config
|
|
273
515
|
|
|
274
|
-
def _prepare_messages(self, query: str, config: AdvancedConfig) ->
|
|
516
|
+
def _prepare_messages(self, query: str, config: AdvancedConfig) -> list[dict[str, str]]:
|
|
275
517
|
"""Build the messages list from query, persona, and config."""
|
|
276
518
|
messages = []
|
|
277
519
|
|
|
@@ -317,15 +559,18 @@ class BaseProvider:
|
|
|
317
559
|
"""Execute a sync function with exponential backoff + jitter."""
|
|
318
560
|
attempts = 0
|
|
319
561
|
last_error = None
|
|
562
|
+
self._retry_count = 0
|
|
320
563
|
|
|
321
564
|
while attempts <= retry:
|
|
322
565
|
try:
|
|
323
566
|
response = func(messages, config, output_type)
|
|
567
|
+
self._retry_count = attempts
|
|
324
568
|
return response
|
|
325
569
|
except Exception as e:
|
|
326
570
|
last_error = e
|
|
327
571
|
if self._is_retryable_error(e) and attempts < retry:
|
|
328
572
|
attempts += 1
|
|
573
|
+
self._retry_count = attempts
|
|
329
574
|
delay = min(2 ** attempts + random.uniform(0, 1), 30)
|
|
330
575
|
logger.warning(
|
|
331
576
|
"Transient error (%s). Retrying in %.1fs... (%d/%d)",
|
|
@@ -342,15 +587,18 @@ class BaseProvider:
|
|
|
342
587
|
"""Execute an async function with exponential backoff + jitter."""
|
|
343
588
|
attempts = 0
|
|
344
589
|
last_error = None
|
|
590
|
+
self._retry_count = 0
|
|
345
591
|
|
|
346
592
|
while attempts <= retry:
|
|
347
593
|
try:
|
|
348
594
|
response = await func(messages, config, output_type)
|
|
595
|
+
self._retry_count = attempts
|
|
349
596
|
return response
|
|
350
597
|
except Exception as e:
|
|
351
598
|
last_error = e
|
|
352
599
|
if self._is_retryable_error(e) and attempts < retry:
|
|
353
600
|
attempts += 1
|
|
601
|
+
self._retry_count = attempts
|
|
354
602
|
delay = min(2 ** attempts + random.uniform(0, 1), 30)
|
|
355
603
|
logger.warning(
|
|
356
604
|
"Transient error (%s). Retrying in %.1fs... (%d/%d)",
|
|
@@ -365,43 +613,57 @@ class BaseProvider:
|
|
|
365
613
|
|
|
366
614
|
def _is_retryable_error(self, e: Exception) -> bool:
|
|
367
615
|
"""Dynamically detect rate limits and network timeouts across different provider SDKs."""
|
|
368
|
-
from .exceptions import
|
|
369
|
-
|
|
616
|
+
from .exceptions import (
|
|
617
|
+
AskAINetworkError,
|
|
618
|
+
AskAIRateLimitError,
|
|
619
|
+
ProviderRateLimitError,
|
|
620
|
+
ProviderTimeoutError,
|
|
621
|
+
ProviderUnavailableError,
|
|
622
|
+
)
|
|
623
|
+
if isinstance(e, (AskAINetworkError, AskAIRateLimitError, ProviderRateLimitError,
|
|
624
|
+
ProviderTimeoutError, ProviderUnavailableError)):
|
|
370
625
|
return True
|
|
371
|
-
|
|
372
|
-
err_str = str(e).lower()
|
|
373
|
-
err_cls = e.__class__.__name__.lower()
|
|
374
|
-
|
|
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)
|
|
626
|
+
return False
|
|
377
627
|
|
|
378
628
|
# ─── Abstract Methods (Subclasses Must Implement) ──────
|
|
379
629
|
|
|
380
|
-
def _send_request(self, messages:
|
|
630
|
+
def _send_request(self, messages: list[dict[str, str]], config: AdvancedConfig, output_type: str = None) -> Response:
|
|
381
631
|
"""Sync request — subclasses must implement."""
|
|
382
632
|
raise NotImplementedError
|
|
383
633
|
|
|
384
|
-
async def _send_request_async(self, messages:
|
|
634
|
+
async def _send_request_async(self, messages: list[dict[str, str]], config: AdvancedConfig, output_type: str = None) -> Response:
|
|
385
635
|
"""Async request — subclasses should override for native async."""
|
|
386
636
|
# Default fallback: run sync in thread pool
|
|
387
637
|
loop = asyncio.get_running_loop()
|
|
388
638
|
return await loop.run_in_executor(None, self._send_request, messages, config, output_type)
|
|
389
639
|
|
|
390
|
-
def _send_request_stream(self, messages:
|
|
640
|
+
def _send_request_stream(self, messages: list[dict[str, str]], config: AdvancedConfig) -> Iterator[str]:
|
|
391
641
|
"""Sync streaming — subclasses should override."""
|
|
392
642
|
from .exceptions import StreamNotSupportedError
|
|
393
643
|
raise StreamNotSupportedError(f"{self.__class__.__name__} does not support streaming.")
|
|
394
644
|
|
|
395
|
-
|
|
645
|
+
def _send_request_stream_events(self, messages: list[dict[str, str]], config: AdvancedConfig) -> Iterator[Any]:
|
|
646
|
+
for index, chunk in enumerate(self._send_request_stream(messages, config)):
|
|
647
|
+
yield TextDelta(text=chunk, index=index)
|
|
648
|
+
|
|
649
|
+
async def _send_request_stream_async(self, messages: list[dict[str, str]], config: AdvancedConfig) -> AsyncIterator[str]:
|
|
396
650
|
"""Async streaming — subclasses should override for native async."""
|
|
397
651
|
# Default fallback: wrap sync stream
|
|
398
652
|
loop = asyncio.get_running_loop()
|
|
399
653
|
for chunk in await loop.run_in_executor(None, lambda: list(self._send_request_stream(messages, config))):
|
|
400
654
|
yield chunk
|
|
401
655
|
|
|
656
|
+
async def _send_request_stream_events_async(
|
|
657
|
+
self, messages: list[dict[str, str]], config: AdvancedConfig
|
|
658
|
+
) -> AsyncIterator[Any]:
|
|
659
|
+
index = 0
|
|
660
|
+
async for chunk in self._send_request_stream_async(messages, config):
|
|
661
|
+
yield TextDelta(text=chunk, index=index)
|
|
662
|
+
index += 1
|
|
663
|
+
|
|
402
664
|
# ─── Tool Definitions for Smart Intent ─────────────────
|
|
403
665
|
|
|
404
|
-
def _get_media_tools(self) ->
|
|
666
|
+
def _get_media_tools(self) -> list[dict[str, Any]]:
|
|
405
667
|
return [
|
|
406
668
|
{
|
|
407
669
|
"type": "function",
|
ask_ai/capabilities.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from dataclasses import asdict, dataclass
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass(frozen=True)
|
|
6
|
+
class Capabilities:
|
|
7
|
+
text: bool = True
|
|
8
|
+
streaming: bool = False
|
|
9
|
+
async_streaming: bool = False
|
|
10
|
+
structured_output: bool = False
|
|
11
|
+
tool_calls: bool = False
|
|
12
|
+
image_generation: bool = False
|
|
13
|
+
audio_generation: bool = False
|
|
14
|
+
vision: bool = False
|
|
15
|
+
|
|
16
|
+
def to_dict(self) -> dict[str, Any]:
|
|
17
|
+
return asdict(self)
|
ask_ai/cli.py
CHANGED
|
@@ -1,16 +1,7 @@
|
|
|
1
|
-
import sys
|
|
2
1
|
import argparse
|
|
3
|
-
|
|
4
|
-
from . import (
|
|
5
|
-
OpenAI,
|
|
6
|
-
Groq,
|
|
7
|
-
Google,
|
|
8
|
-
OpenRouter,
|
|
9
|
-
Azure,
|
|
10
|
-
Anthropic,
|
|
11
|
-
AskAIError
|
|
12
|
-
)
|
|
2
|
+
import sys
|
|
13
3
|
|
|
4
|
+
from . import Anthropic, AskAIError, Azure, Google, Groq, OpenAI, OpenRouter, __version__
|
|
14
5
|
|
|
15
6
|
PROVIDER_MAP = {
|
|
16
7
|
"openai": OpenAI,
|
|
@@ -70,14 +61,9 @@ def main():
|
|
|
70
61
|
|
|
71
62
|
# Stream or regular
|
|
72
63
|
if args.stream:
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
print() # newline at end
|
|
77
|
-
except Exception:
|
|
78
|
-
# Fallback to non-streaming if provider doesn't support it
|
|
79
|
-
response = ai.ask(args.query, **ask_kwargs)
|
|
80
|
-
print(response.text)
|
|
64
|
+
for chunk in ai.ask_stream(args.query, **ask_kwargs):
|
|
65
|
+
print(chunk, end="", flush=True)
|
|
66
|
+
print() # newline at end
|
|
81
67
|
else:
|
|
82
68
|
response = ai.ask(args.query, **ask_kwargs)
|
|
83
69
|
|