askai-python 0.5.0__py3-none-any.whl → 0.6.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 +3 -1
- ask_ai/base.py +201 -26
- ask_ai/config.py +49 -1
- ask_ai/exceptions.py +17 -2
- ask_ai/media_security.py +50 -3
- ask_ai/observability.py +31 -0
- ask_ai/providers/anthropic_provider.py +84 -4
- ask_ai/providers/azure_provider.py +9 -2
- ask_ai/providers/google_provider.py +172 -80
- ask_ai/providers/groq_provider.py +109 -6
- ask_ai/providers/openai_provider.py +118 -15
- ask_ai/providers/openrouter_provider.py +10 -2
- ask_ai/py.typed +0 -0
- ask_ai/requirements.txt +2 -2
- ask_ai/session.py +32 -6
- ask_ai/structured.py +223 -2
- {askai_python-0.5.0.dist-info → askai_python-0.6.0.dist-info}/METADATA +29 -10
- askai_python-0.6.0.dist-info/RECORD +30 -0
- askai_python-0.5.0.dist-info/RECORD +0 -29
- {askai_python-0.5.0.dist-info → askai_python-0.6.0.dist-info}/WHEEL +0 -0
- {askai_python-0.5.0.dist-info → askai_python-0.6.0.dist-info}/entry_points.txt +0 -0
- {askai_python-0.5.0.dist-info → askai_python-0.6.0.dist-info}/licenses/LICENSE +0 -0
- {askai_python-0.5.0.dist-info → askai_python-0.6.0.dist-info}/top_level.txt +0 -0
ask_ai/__init__.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
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.6.0"
|
|
6
6
|
|
|
7
7
|
from .base import Response
|
|
8
8
|
from .capabilities import Capabilities
|
|
@@ -17,6 +17,7 @@ from .exceptions import (
|
|
|
17
17
|
MediaSecurityError,
|
|
18
18
|
MediaTypeNotSupportedError,
|
|
19
19
|
ProviderAuthError,
|
|
20
|
+
ProviderConnectionError,
|
|
20
21
|
ProviderError,
|
|
21
22
|
ProviderInvalidRequestError,
|
|
22
23
|
ProviderRateLimitError,
|
|
@@ -54,6 +55,7 @@ __all__ = [
|
|
|
54
55
|
"AskAIParsingError",
|
|
55
56
|
"ProviderError",
|
|
56
57
|
"ProviderAuthError",
|
|
58
|
+
"ProviderConnectionError",
|
|
57
59
|
"ProviderRateLimitError",
|
|
58
60
|
"ProviderTimeoutError",
|
|
59
61
|
"ProviderInvalidRequestError",
|
ask_ai/base.py
CHANGED
|
@@ -7,13 +7,14 @@ from collections.abc import AsyncIterator, Iterator
|
|
|
7
7
|
from typing import Any, Optional, Union
|
|
8
8
|
|
|
9
9
|
from .capabilities import Capabilities
|
|
10
|
-
from .config import AdvancedConfig
|
|
10
|
+
from .config import CONFIG_OPTION_NAMES, AdvancedConfig, validate_option_names
|
|
11
11
|
from .events import Completed, Error, TextDelta, ToolCall, UsageUpdate
|
|
12
12
|
from .exceptions import StructuredValidationError
|
|
13
13
|
from .media import AudioObject, ImageObject
|
|
14
14
|
from .observability import Observability, ResponseMetadata, Usage
|
|
15
15
|
|
|
16
16
|
logger = logging.getLogger("ask_ai")
|
|
17
|
+
DEFAULT_RETRY_COUNT = 2
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
class Response:
|
|
@@ -26,12 +27,14 @@ class Response:
|
|
|
26
27
|
media: Union[ImageObject, AudioObject, None] = None,
|
|
27
28
|
response_model: Any = None,
|
|
28
29
|
metadata: Any = None,
|
|
30
|
+
usage: Optional[Usage] = None,
|
|
29
31
|
**kwargs,
|
|
30
32
|
):
|
|
31
33
|
self._raw_text = text
|
|
32
34
|
self.media = media
|
|
33
35
|
self.response_model = response_model
|
|
34
36
|
self.metadata = metadata
|
|
37
|
+
self.usage = usage
|
|
35
38
|
self._kwargs = kwargs
|
|
36
39
|
|
|
37
40
|
@property
|
|
@@ -110,7 +113,18 @@ class BaseProvider:
|
|
|
110
113
|
Abstract base class for all AI providers.
|
|
111
114
|
Implements the core 'ask' logic and configuration management.
|
|
112
115
|
"""
|
|
113
|
-
|
|
116
|
+
option_names = frozenset()
|
|
117
|
+
|
|
118
|
+
def __init__(
|
|
119
|
+
self,
|
|
120
|
+
api_key: str = None,
|
|
121
|
+
model: str = None,
|
|
122
|
+
persona: str = None,
|
|
123
|
+
strict_options: bool = False,
|
|
124
|
+
**kwargs,
|
|
125
|
+
):
|
|
126
|
+
self.strict_options = strict_options
|
|
127
|
+
self._validate_option_names(kwargs, CONFIG_OPTION_NAMES | self.option_names, strict_options)
|
|
114
128
|
# Zero-Config: Try env var if key not provided
|
|
115
129
|
self.api_key = api_key or os.environ.get(self._get_api_key_env_var())
|
|
116
130
|
self.model = model or self._get_default_model()
|
|
@@ -118,14 +132,33 @@ class BaseProvider:
|
|
|
118
132
|
self.config = AdvancedConfig(**kwargs)
|
|
119
133
|
self._middleware: list[Observability] = []
|
|
120
134
|
|
|
135
|
+
@staticmethod
|
|
136
|
+
def _validate_option_names(options, allowed, strict=False):
|
|
137
|
+
validate_option_names(options, allowed, strict)
|
|
138
|
+
|
|
121
139
|
def use(self, middleware: Observability) -> "BaseProvider":
|
|
122
140
|
"""Attach optional observability middleware and return this provider."""
|
|
123
141
|
self._middleware.append(middleware)
|
|
124
142
|
return self
|
|
125
143
|
|
|
126
|
-
def session(
|
|
144
|
+
def session(
|
|
145
|
+
self,
|
|
146
|
+
max_messages: Optional[int] = None,
|
|
147
|
+
strict_options: Optional[bool] = None,
|
|
148
|
+
**kwargs,
|
|
149
|
+
):
|
|
150
|
+
strict = (
|
|
151
|
+
getattr(self, "strict_options", False)
|
|
152
|
+
if strict_options is None
|
|
153
|
+
else strict_options
|
|
154
|
+
)
|
|
155
|
+
validate_option_names(kwargs, {"max_messages", "strict_options"}, strict)
|
|
127
156
|
from .session import ConversationSession
|
|
128
|
-
return ConversationSession(
|
|
157
|
+
return ConversationSession(
|
|
158
|
+
self,
|
|
159
|
+
max_messages=max_messages,
|
|
160
|
+
strict_options=strict,
|
|
161
|
+
)
|
|
129
162
|
|
|
130
163
|
def capabilities(self) -> Capabilities:
|
|
131
164
|
"""Return conservative capabilities for custom providers."""
|
|
@@ -197,6 +230,13 @@ class BaseProvider:
|
|
|
197
230
|
response = self._execute_with_retry(
|
|
198
231
|
self._send_request, request_messages, config, output_type, retry
|
|
199
232
|
)
|
|
233
|
+
response._kwargs.update(
|
|
234
|
+
{
|
|
235
|
+
option: config.extra[option]
|
|
236
|
+
for option in ("clean", "strip", "code")
|
|
237
|
+
if option in config.extra
|
|
238
|
+
}
|
|
239
|
+
)
|
|
200
240
|
retry_count += getattr(self, "_retry_count", 0)
|
|
201
241
|
if response_model is None:
|
|
202
242
|
return self._attach_metadata(response, started_at, retry_count)
|
|
@@ -234,6 +274,13 @@ class BaseProvider:
|
|
|
234
274
|
response = await self._execute_with_retry_async(
|
|
235
275
|
self._send_request_async, request_messages, config, output_type, retry
|
|
236
276
|
)
|
|
277
|
+
response._kwargs.update(
|
|
278
|
+
{
|
|
279
|
+
option: config.extra[option]
|
|
280
|
+
for option in ("clean", "strip", "code")
|
|
281
|
+
if option in config.extra
|
|
282
|
+
}
|
|
283
|
+
)
|
|
237
284
|
retry_count += getattr(self, "_retry_count", 0)
|
|
238
285
|
if response_model is None:
|
|
239
286
|
return self._attach_metadata(response, started_at, retry_count)
|
|
@@ -265,6 +312,8 @@ class BaseProvider:
|
|
|
265
312
|
Update global advanced settings for this instance.
|
|
266
313
|
Merges new settings with existing ones.
|
|
267
314
|
"""
|
|
315
|
+
strict = kwargs.pop("strict_options", self.strict_options)
|
|
316
|
+
self._validate_option_names(kwargs, CONFIG_OPTION_NAMES | self.option_names, strict)
|
|
268
317
|
new_conf = AdvancedConfig(**kwargs)
|
|
269
318
|
for k, v in new_conf.__dict__.items():
|
|
270
319
|
if k in ('extra', '_safe_mode_provided'):
|
|
@@ -308,7 +357,9 @@ class BaseProvider:
|
|
|
308
357
|
final_config = self._build_final_config(kwargs)
|
|
309
358
|
messages = self._prepare_messages(query, final_config)
|
|
310
359
|
output_type = kwargs.get('output_type')
|
|
311
|
-
retry = int(
|
|
360
|
+
retry = int(
|
|
361
|
+
kwargs.get("retry", final_config.extra.get("retry", DEFAULT_RETRY_COUNT)) or 0
|
|
362
|
+
)
|
|
312
363
|
response_model = kwargs.get('response_model')
|
|
313
364
|
validation_retries = int(kwargs.get("validation_retries", 0) or 0)
|
|
314
365
|
|
|
@@ -352,7 +403,14 @@ class BaseProvider:
|
|
|
352
403
|
|
|
353
404
|
final_config = self._build_final_config(kwargs)
|
|
354
405
|
messages = self._prepare_messages(query, final_config)
|
|
355
|
-
|
|
406
|
+
retry = int(
|
|
407
|
+
kwargs.get("retry", final_config.extra.get("retry", DEFAULT_RETRY_COUNT))
|
|
408
|
+
or 0
|
|
409
|
+
)
|
|
410
|
+
yield from self._iter_with_retry(
|
|
411
|
+
lambda: self._send_request_stream(messages, final_config),
|
|
412
|
+
retry,
|
|
413
|
+
)
|
|
356
414
|
|
|
357
415
|
def ask_stream_events(self, query: str, **kwargs) -> Iterator[Any]:
|
|
358
416
|
"""Yield typed events while preserving the legacy text stream API."""
|
|
@@ -360,18 +418,44 @@ class BaseProvider:
|
|
|
360
418
|
if fallback_providers:
|
|
361
419
|
if not isinstance(fallback_providers, (list, tuple)):
|
|
362
420
|
fallback_providers = [fallback_providers]
|
|
421
|
+
last_error = None
|
|
363
422
|
for provider in fallback_providers:
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
423
|
+
emitted = False
|
|
424
|
+
try:
|
|
425
|
+
provider_instance = provider() if isinstance(provider, type) else provider
|
|
426
|
+
for event in provider_instance.ask_stream_events(query, **kwargs):
|
|
427
|
+
if isinstance(event, Error):
|
|
428
|
+
if not emitted and event.emitted_chunks == 0:
|
|
429
|
+
last_error = event
|
|
430
|
+
break
|
|
431
|
+
yield event
|
|
432
|
+
return
|
|
433
|
+
emitted = True
|
|
434
|
+
yield event
|
|
435
|
+
else:
|
|
436
|
+
return
|
|
437
|
+
except Exception as error:
|
|
438
|
+
if emitted:
|
|
439
|
+
raise
|
|
440
|
+
last_error = Error(error=error, emitted_chunks=0)
|
|
441
|
+
if last_error is not None:
|
|
442
|
+
yield last_error
|
|
443
|
+
return
|
|
367
444
|
|
|
368
445
|
emitted_chunks = 0
|
|
369
446
|
text_parts = []
|
|
370
447
|
usage = None
|
|
371
448
|
final_config = self._build_final_config(kwargs)
|
|
372
449
|
messages = self._prepare_messages(query, final_config)
|
|
450
|
+
retry = int(
|
|
451
|
+
kwargs.get("retry", final_config.extra.get("retry", DEFAULT_RETRY_COUNT))
|
|
452
|
+
or 0
|
|
453
|
+
)
|
|
373
454
|
try:
|
|
374
|
-
for event in self.
|
|
455
|
+
for event in self._iter_with_retry(
|
|
456
|
+
lambda: self._send_request_stream_events(messages, final_config),
|
|
457
|
+
retry,
|
|
458
|
+
):
|
|
375
459
|
if isinstance(event, TextDelta):
|
|
376
460
|
emitted_chunks += 1
|
|
377
461
|
text_parts.append(event.text)
|
|
@@ -419,7 +503,9 @@ class BaseProvider:
|
|
|
419
503
|
final_config = self._build_final_config(kwargs)
|
|
420
504
|
messages = self._prepare_messages(query, final_config)
|
|
421
505
|
output_type = kwargs.get('output_type')
|
|
422
|
-
retry = int(
|
|
506
|
+
retry = int(
|
|
507
|
+
kwargs.get("retry", final_config.extra.get("retry", DEFAULT_RETRY_COUNT)) or 0
|
|
508
|
+
)
|
|
423
509
|
response_model = kwargs.get('response_model')
|
|
424
510
|
validation_retries = int(kwargs.get("validation_retries", 0) or 0)
|
|
425
511
|
|
|
@@ -463,7 +549,14 @@ class BaseProvider:
|
|
|
463
549
|
|
|
464
550
|
final_config = self._build_final_config(kwargs)
|
|
465
551
|
messages = self._prepare_messages(query, final_config)
|
|
466
|
-
|
|
552
|
+
retry = int(
|
|
553
|
+
kwargs.get("retry", final_config.extra.get("retry", DEFAULT_RETRY_COUNT))
|
|
554
|
+
or 0
|
|
555
|
+
)
|
|
556
|
+
async for chunk in self._aiter_with_retry(
|
|
557
|
+
lambda: self._send_request_stream_async(messages, final_config),
|
|
558
|
+
retry,
|
|
559
|
+
):
|
|
467
560
|
yield chunk
|
|
468
561
|
|
|
469
562
|
async def ask_stream_events_async(self, query: str, **kwargs) -> AsyncIterator[Any]:
|
|
@@ -472,19 +565,44 @@ class BaseProvider:
|
|
|
472
565
|
if fallback_providers:
|
|
473
566
|
if not isinstance(fallback_providers, (list, tuple)):
|
|
474
567
|
fallback_providers = [fallback_providers]
|
|
568
|
+
last_error = None
|
|
475
569
|
for provider in fallback_providers:
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
570
|
+
emitted = False
|
|
571
|
+
try:
|
|
572
|
+
provider_instance = provider() if isinstance(provider, type) else provider
|
|
573
|
+
async for event in provider_instance.ask_stream_events_async(query, **kwargs):
|
|
574
|
+
if isinstance(event, Error):
|
|
575
|
+
if not emitted and event.emitted_chunks == 0:
|
|
576
|
+
last_error = event
|
|
577
|
+
break
|
|
578
|
+
yield event
|
|
579
|
+
return
|
|
580
|
+
emitted = True
|
|
581
|
+
yield event
|
|
582
|
+
else:
|
|
583
|
+
return
|
|
584
|
+
except Exception as error:
|
|
585
|
+
if emitted:
|
|
586
|
+
raise
|
|
587
|
+
last_error = Error(error=error, emitted_chunks=0)
|
|
588
|
+
if last_error is not None:
|
|
589
|
+
yield last_error
|
|
590
|
+
return
|
|
480
591
|
|
|
481
592
|
emitted_chunks = 0
|
|
482
593
|
text_parts = []
|
|
483
594
|
usage = None
|
|
484
595
|
final_config = self._build_final_config(kwargs)
|
|
485
596
|
messages = self._prepare_messages(query, final_config)
|
|
597
|
+
retry = int(
|
|
598
|
+
kwargs.get("retry", final_config.extra.get("retry", DEFAULT_RETRY_COUNT))
|
|
599
|
+
or 0
|
|
600
|
+
)
|
|
486
601
|
try:
|
|
487
|
-
async for event in self.
|
|
602
|
+
async for event in self._aiter_with_retry(
|
|
603
|
+
lambda: self._send_request_stream_events_async(messages, final_config),
|
|
604
|
+
retry,
|
|
605
|
+
):
|
|
488
606
|
if isinstance(event, TextDelta):
|
|
489
607
|
emitted_chunks += 1
|
|
490
608
|
text_parts.append(event.text)
|
|
@@ -504,11 +622,20 @@ class BaseProvider:
|
|
|
504
622
|
|
|
505
623
|
def _build_final_config(self, kwargs: dict[str, Any]) -> AdvancedConfig:
|
|
506
624
|
"""Build final config by merging global config with per-request kwargs."""
|
|
507
|
-
|
|
625
|
+
options = dict(kwargs)
|
|
626
|
+
strict = options.pop("strict_options", getattr(self, "strict_options", False))
|
|
627
|
+
validate_option_names(
|
|
628
|
+
options,
|
|
629
|
+
CONFIG_OPTION_NAMES
|
|
630
|
+
| self.option_names
|
|
631
|
+
| {"strict_options", "providers", "fallback"},
|
|
632
|
+
strict,
|
|
633
|
+
)
|
|
634
|
+
request_config = AdvancedConfig(**options)
|
|
508
635
|
final_config = self.config.merge(request_config)
|
|
509
636
|
|
|
510
637
|
# Ensure timeout has a default
|
|
511
|
-
timeout =
|
|
638
|
+
timeout = options.get('timeout', final_config.extra.get('timeout', 30))
|
|
512
639
|
final_config.extra['timeout'] = float(timeout) if timeout is not None else 30.0
|
|
513
640
|
|
|
514
641
|
return final_config
|
|
@@ -521,14 +648,10 @@ class BaseProvider:
|
|
|
521
648
|
|
|
522
649
|
# Smart capability: if json=True or response_model is specified
|
|
523
650
|
response_model = config.extra.get('response_model')
|
|
524
|
-
if response_model:
|
|
525
|
-
|
|
526
|
-
schema = response_model.model_json_schema()
|
|
527
|
-
elif hasattr(response_model, "schema"):
|
|
528
|
-
schema = response_model.schema()
|
|
529
|
-
else:
|
|
530
|
-
schema = None
|
|
651
|
+
if response_model is not None:
|
|
652
|
+
from .structured import json_schema_for_model
|
|
531
653
|
|
|
654
|
+
schema = json_schema_for_model(response_model)
|
|
532
655
|
if schema:
|
|
533
656
|
import json
|
|
534
657
|
schema_str = json.dumps(schema)
|
|
@@ -555,6 +678,58 @@ class BaseProvider:
|
|
|
555
678
|
|
|
556
679
|
# ─── Retry Logic ────────────────────────────────────────
|
|
557
680
|
|
|
681
|
+
def _iter_with_retry(self, iterator_factory, retry: int):
|
|
682
|
+
attempts = 0
|
|
683
|
+
self._retry_count = 0
|
|
684
|
+
while True:
|
|
685
|
+
emitted = False
|
|
686
|
+
try:
|
|
687
|
+
for item in iterator_factory():
|
|
688
|
+
emitted = True
|
|
689
|
+
yield item
|
|
690
|
+
self._retry_count = attempts
|
|
691
|
+
return
|
|
692
|
+
except Exception as error:
|
|
693
|
+
if emitted or attempts >= retry or not self._is_retryable_error(error):
|
|
694
|
+
raise
|
|
695
|
+
attempts += 1
|
|
696
|
+
self._retry_count = attempts
|
|
697
|
+
delay = min(2 ** attempts + random.uniform(0, 1), 30)
|
|
698
|
+
logger.warning(
|
|
699
|
+
"Transient streaming error (%s). Retrying in %.1fs... (%d/%d)",
|
|
700
|
+
error.__class__.__name__,
|
|
701
|
+
delay,
|
|
702
|
+
attempts,
|
|
703
|
+
retry,
|
|
704
|
+
)
|
|
705
|
+
time.sleep(delay)
|
|
706
|
+
|
|
707
|
+
async def _aiter_with_retry(self, iterator_factory, retry: int):
|
|
708
|
+
attempts = 0
|
|
709
|
+
self._retry_count = 0
|
|
710
|
+
while True:
|
|
711
|
+
emitted = False
|
|
712
|
+
try:
|
|
713
|
+
async for item in iterator_factory():
|
|
714
|
+
emitted = True
|
|
715
|
+
yield item
|
|
716
|
+
self._retry_count = attempts
|
|
717
|
+
return
|
|
718
|
+
except Exception as error:
|
|
719
|
+
if emitted or attempts >= retry or not self._is_retryable_error(error):
|
|
720
|
+
raise
|
|
721
|
+
attempts += 1
|
|
722
|
+
self._retry_count = attempts
|
|
723
|
+
delay = min(2 ** attempts + random.uniform(0, 1), 30)
|
|
724
|
+
logger.warning(
|
|
725
|
+
"Transient async streaming error (%s). Retrying in %.1fs... (%d/%d)",
|
|
726
|
+
error.__class__.__name__,
|
|
727
|
+
delay,
|
|
728
|
+
attempts,
|
|
729
|
+
retry,
|
|
730
|
+
)
|
|
731
|
+
await asyncio.sleep(delay)
|
|
732
|
+
|
|
558
733
|
def _execute_with_retry(self, func, messages, config, output_type, retry):
|
|
559
734
|
"""Execute a sync function with exponential backoff + jitter."""
|
|
560
735
|
attempts = 0
|
ask_ai/config.py
CHANGED
|
@@ -1,7 +1,49 @@
|
|
|
1
|
-
|
|
1
|
+
import difflib
|
|
2
|
+
import warnings
|
|
3
|
+
from typing import Any, Iterable, Optional
|
|
2
4
|
|
|
3
5
|
_NOT_PROVIDED = object()
|
|
4
6
|
|
|
7
|
+
CONFIG_OPTION_NAMES = frozenset(
|
|
8
|
+
{
|
|
9
|
+
"temperature",
|
|
10
|
+
"max_tokens",
|
|
11
|
+
"top_p",
|
|
12
|
+
"frequency_penalty",
|
|
13
|
+
"presence_penalty",
|
|
14
|
+
"stop",
|
|
15
|
+
"system_message",
|
|
16
|
+
"prompt",
|
|
17
|
+
"system",
|
|
18
|
+
"safe_mode",
|
|
19
|
+
"timeout",
|
|
20
|
+
"retry",
|
|
21
|
+
"response_model",
|
|
22
|
+
"validation_retries",
|
|
23
|
+
"output_type",
|
|
24
|
+
"clean",
|
|
25
|
+
"strip",
|
|
26
|
+
"code",
|
|
27
|
+
"json",
|
|
28
|
+
}
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def validate_option_names(
|
|
33
|
+
options: Iterable[str],
|
|
34
|
+
allowed: Iterable[str],
|
|
35
|
+
strict: bool = False,
|
|
36
|
+
) -> None:
|
|
37
|
+
allowed_set = set(allowed)
|
|
38
|
+
allowed_names = sorted(allowed_set)
|
|
39
|
+
for name in sorted(set(options) - allowed_set):
|
|
40
|
+
suggestions = difflib.get_close_matches(name, allowed_names, n=1, cutoff=0.6)
|
|
41
|
+
suggestion = f" Did you mean '{suggestions[0]}'?" if suggestions else ""
|
|
42
|
+
message = f"Unknown option '{name}'.{suggestion}"
|
|
43
|
+
if strict:
|
|
44
|
+
raise ValueError(message)
|
|
45
|
+
warnings.warn(message, UserWarning, stacklevel=3)
|
|
46
|
+
|
|
5
47
|
|
|
6
48
|
class AdvancedConfig:
|
|
7
49
|
"""
|
|
@@ -37,6 +79,12 @@ class AdvancedConfig:
|
|
|
37
79
|
kwargs.pop("system", None)
|
|
38
80
|
|
|
39
81
|
self.system_message = system_message
|
|
82
|
+
if safe_mode is not _NOT_PROVIDED:
|
|
83
|
+
warnings.warn(
|
|
84
|
+
"safe_mode is deprecated and has no defined behavior; it will be removed in a future release.",
|
|
85
|
+
DeprecationWarning,
|
|
86
|
+
stacklevel=2,
|
|
87
|
+
)
|
|
40
88
|
self.safe_mode = False if safe_mode is _NOT_PROVIDED else safe_mode
|
|
41
89
|
self._safe_mode_provided = safe_mode is not _NOT_PROVIDED
|
|
42
90
|
self.extra: dict[str, Any] = kwargs
|
ask_ai/exceptions.py
CHANGED
|
@@ -38,6 +38,11 @@ class ProviderTimeoutError(AskAINetworkError, ProviderError):
|
|
|
38
38
|
pass
|
|
39
39
|
|
|
40
40
|
|
|
41
|
+
class ProviderConnectionError(AskAINetworkError, ProviderError):
|
|
42
|
+
"""Raised when a provider request fails due to a network connection error."""
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
41
46
|
class ProviderInvalidRequestError(ProviderError):
|
|
42
47
|
"""Raised when a provider rejects request parameters."""
|
|
43
48
|
pass
|
|
@@ -83,6 +88,8 @@ def map_provider_exception(error: Exception, provider: str, operation: str = "re
|
|
|
83
88
|
if status_code is None:
|
|
84
89
|
response = getattr(error, "response", None)
|
|
85
90
|
status_code = getattr(response, "status_code", None)
|
|
91
|
+
if status_code is None:
|
|
92
|
+
status_code = getattr(error, "code", None)
|
|
86
93
|
|
|
87
94
|
error_text = str(error).lower()
|
|
88
95
|
error_name = error.__class__.__name__.lower()
|
|
@@ -98,11 +105,19 @@ def map_provider_exception(error: Exception, provider: str, operation: str = "re
|
|
|
98
105
|
for marker in ("ratelimit", "rate_limit", "rate limit", "too many requests")
|
|
99
106
|
):
|
|
100
107
|
return ProviderRateLimitError(f"{prefix}: {error}")
|
|
108
|
+
if any(marker in error_name or marker in error_text for marker in ("timeout", "timed out")):
|
|
109
|
+
return ProviderTimeoutError(f"{prefix}: {error}")
|
|
101
110
|
if any(
|
|
102
111
|
marker in error_name or marker in error_text
|
|
103
|
-
for marker in (
|
|
112
|
+
for marker in (
|
|
113
|
+
"connecterror",
|
|
114
|
+
"connectionerror",
|
|
115
|
+
"connection reset",
|
|
116
|
+
"connection refused",
|
|
117
|
+
"connection error",
|
|
118
|
+
)
|
|
104
119
|
):
|
|
105
|
-
return
|
|
120
|
+
return ProviderConnectionError(f"{prefix}: {error}")
|
|
106
121
|
if isinstance(status_code, int) and status_code >= 500:
|
|
107
122
|
return ProviderUnavailableError(f"{prefix}: {error}")
|
|
108
123
|
if status_code in (400, 404, 422) or any(
|
ask_ai/media_security.py
CHANGED
|
@@ -16,7 +16,7 @@ class MediaPolicy:
|
|
|
16
16
|
timeout: float = 30.0
|
|
17
17
|
allow_private: bool = False
|
|
18
18
|
|
|
19
|
-
def
|
|
19
|
+
def _validated_addresses(self, url: str) -> tuple[str, list[str]]:
|
|
20
20
|
parsed = urlparse(url)
|
|
21
21
|
if parsed.scheme != "https" or not parsed.hostname:
|
|
22
22
|
raise MediaSecurityError("Media URLs must use HTTPS and include a hostname.")
|
|
@@ -53,6 +53,10 @@ class MediaPolicy:
|
|
|
53
53
|
raise MediaSecurityError(
|
|
54
54
|
f"Media host resolves to a non-public IP address: {address}"
|
|
55
55
|
)
|
|
56
|
+
return parsed.hostname, [str(address) for address in addresses]
|
|
57
|
+
|
|
58
|
+
def validate_url(self, url: str) -> None:
|
|
59
|
+
self._validated_addresses(url)
|
|
56
60
|
|
|
57
61
|
def validate_mime(self, mime_type: str) -> None:
|
|
58
62
|
if self.allowed_mime_types and mime_type not in self.allowed_mime_types:
|
|
@@ -62,14 +66,19 @@ class MediaPolicy:
|
|
|
62
66
|
def download_media(url: str, policy: Optional[MediaPolicy] = None) -> bytes:
|
|
63
67
|
"""Stream media bytes with URL, MIME, size, and timeout validation."""
|
|
64
68
|
policy = policy or MediaPolicy()
|
|
65
|
-
policy.
|
|
69
|
+
hostname, addresses = policy._validated_addresses(url)
|
|
66
70
|
try:
|
|
67
71
|
import httpx
|
|
68
72
|
except ImportError as error:
|
|
69
73
|
raise MediaSecurityError("httpx is required for secure media downloads.") from error
|
|
70
74
|
|
|
71
75
|
try:
|
|
72
|
-
|
|
76
|
+
transport = _create_pinned_transport(httpx, hostname, addresses)
|
|
77
|
+
with httpx.Client(
|
|
78
|
+
timeout=policy.timeout,
|
|
79
|
+
follow_redirects=False,
|
|
80
|
+
transport=transport,
|
|
81
|
+
) as client:
|
|
73
82
|
with client.stream("GET", url) as response:
|
|
74
83
|
if 300 <= response.status_code < 400:
|
|
75
84
|
raise MediaSecurityError(
|
|
@@ -103,3 +112,41 @@ def download_media(url: str, policy: Optional[MediaPolicy] = None) -> bytes:
|
|
|
103
112
|
raise
|
|
104
113
|
except Exception as error:
|
|
105
114
|
raise MediaSecurityError(f"Secure media download failed: {error}") from error
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _create_pinned_transport(httpx, hostname: str, addresses: list[str]):
|
|
118
|
+
import httpcore
|
|
119
|
+
|
|
120
|
+
normalized_hostname = hostname.encode("idna").decode("ascii").lower().rstrip(".")
|
|
121
|
+
|
|
122
|
+
class PinnedNetworkBackend(httpcore.SyncBackend):
|
|
123
|
+
def connect_tcp(
|
|
124
|
+
self,
|
|
125
|
+
host,
|
|
126
|
+
port,
|
|
127
|
+
timeout=None,
|
|
128
|
+
local_address=None,
|
|
129
|
+
socket_options=None,
|
|
130
|
+
):
|
|
131
|
+
if host.lower().rstrip(".") != normalized_hostname:
|
|
132
|
+
raise httpcore.ConnectError(
|
|
133
|
+
"Media transport attempted to connect to an unvalidated host."
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
last_error = None
|
|
137
|
+
for address in addresses:
|
|
138
|
+
try:
|
|
139
|
+
return super().connect_tcp(
|
|
140
|
+
address,
|
|
141
|
+
port,
|
|
142
|
+
timeout=timeout,
|
|
143
|
+
local_address=local_address,
|
|
144
|
+
socket_options=socket_options,
|
|
145
|
+
)
|
|
146
|
+
except (httpcore.ConnectError, httpcore.ConnectTimeout) as error:
|
|
147
|
+
last_error = error
|
|
148
|
+
raise last_error
|
|
149
|
+
|
|
150
|
+
transport = httpx.HTTPTransport()
|
|
151
|
+
transport._pool._network_backend = PinnedNetworkBackend()
|
|
152
|
+
return transport
|
ask_ai/observability.py
CHANGED
|
@@ -9,6 +9,37 @@ class Usage:
|
|
|
9
9
|
total_tokens: Optional[int] = None
|
|
10
10
|
|
|
11
11
|
|
|
12
|
+
def usage_from_fields(
|
|
13
|
+
source: Any,
|
|
14
|
+
input_field: str,
|
|
15
|
+
output_field: str,
|
|
16
|
+
total_field: Optional[str] = None,
|
|
17
|
+
) -> Optional[Usage]:
|
|
18
|
+
"""Normalize provider-specific token usage fields without inventing missing counts."""
|
|
19
|
+
if source is None:
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
def field(name: Optional[str]) -> Optional[int]:
|
|
23
|
+
if name is None:
|
|
24
|
+
return None
|
|
25
|
+
if isinstance(source, dict):
|
|
26
|
+
return source.get(name)
|
|
27
|
+
return getattr(source, name, None)
|
|
28
|
+
|
|
29
|
+
input_tokens = field(input_field)
|
|
30
|
+
output_tokens = field(output_field)
|
|
31
|
+
total_tokens = field(total_field)
|
|
32
|
+
if input_tokens is None and output_tokens is None and total_tokens is None:
|
|
33
|
+
return None
|
|
34
|
+
if total_tokens is None and input_tokens is not None and output_tokens is not None:
|
|
35
|
+
total_tokens = input_tokens + output_tokens
|
|
36
|
+
return Usage(
|
|
37
|
+
input_tokens=input_tokens,
|
|
38
|
+
output_tokens=output_tokens,
|
|
39
|
+
total_tokens=total_tokens,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
12
43
|
@dataclass(frozen=True)
|
|
13
44
|
class ResponseMetadata:
|
|
14
45
|
provider: str
|