askai-python 0.5.0__tar.gz → 0.6.0__tar.gz

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.
Files changed (57) hide show
  1. {askai_python-0.5.0 → askai_python-0.6.0}/PKG-INFO +29 -10
  2. {askai_python-0.5.0 → askai_python-0.6.0}/README.md +57 -6
  3. {askai_python-0.5.0 → askai_python-0.6.0}/ask_ai/__init__.py +3 -1
  4. {askai_python-0.5.0 → askai_python-0.6.0}/ask_ai/base.py +201 -26
  5. {askai_python-0.5.0 → askai_python-0.6.0}/ask_ai/config.py +49 -1
  6. {askai_python-0.5.0 → askai_python-0.6.0}/ask_ai/exceptions.py +17 -2
  7. {askai_python-0.5.0 → askai_python-0.6.0}/ask_ai/media_security.py +50 -3
  8. {askai_python-0.5.0 → askai_python-0.6.0}/ask_ai/observability.py +31 -0
  9. {askai_python-0.5.0 → askai_python-0.6.0}/ask_ai/providers/anthropic_provider.py +84 -4
  10. {askai_python-0.5.0 → askai_python-0.6.0}/ask_ai/providers/azure_provider.py +9 -2
  11. askai_python-0.6.0/ask_ai/providers/google_provider.py +255 -0
  12. {askai_python-0.5.0 → askai_python-0.6.0}/ask_ai/providers/groq_provider.py +109 -6
  13. {askai_python-0.5.0 → askai_python-0.6.0}/ask_ai/providers/openai_provider.py +118 -15
  14. {askai_python-0.5.0 → askai_python-0.6.0}/ask_ai/providers/openrouter_provider.py +10 -2
  15. askai_python-0.6.0/ask_ai/py.typed +0 -0
  16. {askai_python-0.5.0 → askai_python-0.6.0}/ask_ai/requirements.txt +2 -2
  17. {askai_python-0.5.0 → askai_python-0.6.0}/ask_ai/session.py +32 -6
  18. askai_python-0.6.0/ask_ai/structured.py +309 -0
  19. {askai_python-0.5.0 → askai_python-0.6.0}/askai_python.egg-info/PKG-INFO +29 -10
  20. {askai_python-0.5.0 → askai_python-0.6.0}/askai_python.egg-info/SOURCES.txt +9 -1
  21. {askai_python-0.5.0 → askai_python-0.6.0}/askai_python.egg-info/requires.txt +4 -4
  22. {askai_python-0.5.0 → askai_python-0.6.0}/docs/PYPI_README.md +23 -3
  23. {askai_python-0.5.0 → askai_python-0.6.0}/pyproject.toml +9 -9
  24. askai_python-0.6.0/tests/test_core.py +691 -0
  25. {askai_python-0.5.0 → askai_python-0.6.0}/tests/test_gitignore.py +8 -0
  26. askai_python-0.6.0/tests/test_google_genai.py +189 -0
  27. {askai_python-0.5.0 → askai_python-0.6.0}/tests/test_media_security.py +72 -0
  28. askai_python-0.6.0/tests/test_native_structured.py +471 -0
  29. askai_python-0.6.0/tests/test_options_validation.py +136 -0
  30. askai_python-0.6.0/tests/test_packaging.py +8 -0
  31. askai_python-0.6.0/tests/test_release_docs.py +46 -0
  32. {askai_python-0.5.0 → askai_python-0.6.0}/tests/test_session.py +48 -0
  33. askai_python-0.6.0/tests/test_smoke_real.py +63 -0
  34. {askai_python-0.5.0 → askai_python-0.6.0}/tests/test_streaming_events.py +63 -0
  35. {askai_python-0.5.0 → askai_python-0.6.0}/tests/test_structured.py +2 -2
  36. askai_python-0.6.0/tests/test_usage.py +359 -0
  37. askai_python-0.5.0/ask_ai/providers/google_provider.py +0 -163
  38. askai_python-0.5.0/ask_ai/structured.py +0 -88
  39. askai_python-0.5.0/tests/test_core.py +0 -266
  40. {askai_python-0.5.0 → askai_python-0.6.0}/LICENSE +0 -0
  41. {askai_python-0.5.0 → askai_python-0.6.0}/ask_ai/__main__.py +0 -0
  42. {askai_python-0.5.0 → askai_python-0.6.0}/ask_ai/capabilities.py +0 -0
  43. {askai_python-0.5.0 → askai_python-0.6.0}/ask_ai/cli.py +0 -0
  44. {askai_python-0.5.0 → askai_python-0.6.0}/ask_ai/events.py +0 -0
  45. {askai_python-0.5.0 → askai_python-0.6.0}/ask_ai/media.py +0 -0
  46. {askai_python-0.5.0 → askai_python-0.6.0}/ask_ai/providers/__init__.py +0 -0
  47. {askai_python-0.5.0 → askai_python-0.6.0}/ask_ai/router.py +0 -0
  48. {askai_python-0.5.0 → askai_python-0.6.0}/ask_ai/utils.py +0 -0
  49. {askai_python-0.5.0 → askai_python-0.6.0}/askai_python.egg-info/dependency_links.txt +0 -0
  50. {askai_python-0.5.0 → askai_python-0.6.0}/askai_python.egg-info/entry_points.txt +0 -0
  51. {askai_python-0.5.0 → askai_python-0.6.0}/askai_python.egg-info/top_level.txt +0 -0
  52. {askai_python-0.5.0 → askai_python-0.6.0}/setup.cfg +0 -0
  53. {askai_python-0.5.0 → askai_python-0.6.0}/tests/test_capabilities.py +0 -0
  54. {askai_python-0.5.0 → askai_python-0.6.0}/tests/test_observability.py +0 -0
  55. {askai_python-0.5.0 → askai_python-0.6.0}/tests/test_openrouter_release.py +0 -0
  56. {askai_python-0.5.0 → askai_python-0.6.0}/tests/test_python39_compat.py +0 -0
  57. {askai_python-0.5.0 → askai_python-0.6.0}/tests/test_router.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: askai-python
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: AI Made Stupid Simple. Unified API for OpenAI, Groq, Google, Anthropic, and more.
5
5
  Author-email: Hossein Ghorbani <hosseingh1068@gmail.com>
6
6
  License: MIT
@@ -13,7 +13,6 @@ Classifier: Development Status :: 4 - Beta
13
13
  Classifier: Intended Audience :: Developers
14
14
  Classifier: License :: OSI Approved :: MIT License
15
15
  Classifier: Programming Language :: Python :: 3
16
- Classifier: Programming Language :: Python :: 3.9
17
16
  Classifier: Programming Language :: Python :: 3.10
18
17
  Classifier: Programming Language :: Python :: 3.11
19
18
  Classifier: Programming Language :: Python :: 3.12
@@ -21,15 +20,15 @@ Classifier: Programming Language :: Python :: 3.13
21
20
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
21
  Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
23
22
  Classifier: Typing :: Typed
24
- Requires-Python: >=3.9
23
+ Requires-Python: >=3.10
25
24
  Description-Content-Type: text/markdown
26
25
  License-File: LICENSE
27
26
  Provides-Extra: openai
28
27
  Requires-Dist: openai>=1.0.0; extra == "openai"
29
28
  Provides-Extra: anthropic
30
- Requires-Dist: anthropic>=0.20.0; extra == "anthropic"
29
+ Requires-Dist: anthropic>=1.0.0; extra == "anthropic"
31
30
  Provides-Extra: google
32
- Requires-Dist: google-generativeai>=0.5.0; extra == "google"
31
+ Requires-Dist: google-genai>=1.0.0; extra == "google"
33
32
  Provides-Extra: groq
34
33
  Requires-Dist: groq>=0.4.0; extra == "groq"
35
34
  Provides-Extra: media
@@ -41,8 +40,8 @@ Requires-Dist: pydantic>=1.10; extra == "structured"
41
40
  Requires-Dist: jsonschema>=4.0; extra == "structured"
42
41
  Provides-Extra: all
43
42
  Requires-Dist: openai>=1.0.0; extra == "all"
44
- Requires-Dist: anthropic>=0.20.0; extra == "all"
45
- Requires-Dist: google-generativeai>=0.5.0; extra == "all"
43
+ Requires-Dist: anthropic>=1.0.0; extra == "all"
44
+ Requires-Dist: google-genai>=1.0.0; extra == "all"
46
45
  Requires-Dist: groq>=0.4.0; extra == "all"
47
46
  Requires-Dist: Pillow>=9.0.0; extra == "all"
48
47
  Requires-Dist: httpx>=0.24.0; extra == "all"
@@ -64,9 +63,16 @@ No frameworks. No servers. No overengineering.
64
63
  ## ⚡ Quick Start (5 seconds)
65
64
 
66
65
  ```bash
67
- pip install askai-python
66
+ pip install "askai-python[openai,groq]"
68
67
  ```
69
68
 
69
+ Provider SDKs are optional. Install the extras you need:
70
+
71
+ - `pip install "askai-python[openai]"` for OpenAI, Azure, and OpenRouter.
72
+ - `pip install "askai-python[anthropic]"`, `pip install "askai-python[google]"`,
73
+ or `pip install "askai-python[groq]"` for those providers.
74
+ - `pip install "askai-python[all]"` for every provider SDK.
75
+
70
76
  ```python
71
77
  from ask_ai import OpenAI, Groq
72
78
 
@@ -202,20 +208,33 @@ print(user.name) # "Alice"
202
208
  |----------|-------|-------------|
203
209
  | **OpenAI** | `OpenAI` | Text, Images (DALL-E), Vision |
204
210
  | **Anthropic** | `Anthropic` | Text, Vision (Claude 3.5) |
205
- | **Google** | `Google` | Text, Images, Video, Audio |
211
+ | **Google** | `Google` | Text, Vision |
206
212
  | **Groq** | `Groq` | Ultra-fast Llama 3 & Mixtral |
207
213
  | **Azure** | `Azure` | Enterprise-grade OpenAI |
208
214
  | **OpenRouter**| `OpenRouter`| 100+ community models |
209
215
 
210
216
  ---
211
217
 
218
+ ## Conversation Sessions
219
+
220
+ Sessions provide optional in-memory conversation history:
221
+
222
+ ```python
223
+ session = OpenAI().session(max_messages=20)
224
+ session.ask("My name is Ada.")
225
+ print(session.ask("What is my name?").text)
226
+ ```
227
+
228
+ Session state is not persisted, and session requests do not support
229
+ `response_model` validation yet.
230
+
212
231
  ## 🗺️ Roadmap
213
232
 
214
233
  ### 🚀 Roadmap 2.0 (Active)
215
234
  - [x] Provider Fallback chaining & Pydantic Structured Output support
235
+ - [x] Optional in-memory conversation sessions
216
236
  - [ ] Multi-Modal Vision input (image to text) for all key providers
217
237
  - [ ] Native support for tools/function calling execution
218
- - [ ] Memory buffer / session-based conversation management
219
238
 
220
239
  ### 🏁 Roadmap 1.0 (Completed)
221
240
  - ~~[x] Baseline multiple providers~~
@@ -25,7 +25,7 @@
25
25
 
26
26
  [![PyPI version](https://img.shields.io/pypi/v/askai-python.svg)](https://pypi.org/project/askai-python/)
27
27
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
28
- [![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/)
28
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
29
29
 
30
30
 
31
31
 
@@ -34,9 +34,18 @@
34
34
  ## ⚡ Quick Start (5 seconds)
35
35
 
36
36
  ```bash
37
- pip install askai-python
37
+ pip install "askai-python[openai,groq]"
38
38
  ```
39
39
 
40
+ Provider SDKs are optional dependencies. Install the extras you need:
41
+
42
+ - `pip install "askai-python[openai]"` for OpenAI, Azure, and OpenRouter.
43
+ - `pip install "askai-python[anthropic]"`, `pip install "askai-python[google]"`,
44
+ or `pip install "askai-python[groq]"` for those providers.
45
+ - `pip install "askai-python[all]"` for every provider SDK.
46
+
47
+ Python 3.10 or newer is required.
48
+
40
49
  ```python
41
50
  from ask_ai import OpenAI, Groq
42
51
 
@@ -112,6 +121,12 @@ ai = OpenAI()
112
121
  response = ai.ask("Write a script", retry=3, timeout=10)
113
122
  ```
114
123
 
124
+ Requests default to `retry=2` (up to three attempts) for retryable timeout,
125
+ connection, rate-limit (`429`), and server (`5xx`) errors. Authentication and
126
+ invalid-request errors are not retried; use `retry=0` to disable retries.
127
+ Streaming retries occur only before the first chunk/event, so already-emitted
128
+ output is never duplicated.
129
+
115
130
  ### System Configuration
116
131
  Set system prompts and temperature directly:
117
132
 
@@ -124,6 +139,11 @@ ai.advanced(
124
139
  print(ai.ask("How do I optimize a Dockerfile?").text)
125
140
  ```
126
141
 
142
+ Unknown option names produce a `UserWarning` with a spelling suggestion. Pass
143
+ `strict_options=True` to a provider (or to an individual request) to raise a
144
+ `ValueError` instead. `safe_mode` currently has no defined behavior and is
145
+ deprecated; passing it emits a `DeprecationWarning`.
146
+
127
147
  ---
128
148
 
129
149
  ### 🚀 Async Support (FastAPI / Aiogram)
@@ -245,6 +265,24 @@ print(response.raw)
245
265
  print(response.parsed.name)
246
266
  ```
247
267
 
268
+ For supported schemas, `response_model` also requests native JSON Schema output
269
+ from OpenAI/Azure, Anthropic, Groq, Google, and OpenRouter. When an adapter
270
+ receives a clear unsupported-schema response (HTTP 400/422), it retries once
271
+ without the native schema and keeps local validation enabled. Authentication,
272
+ rate-limit, and server errors are propagated, not retried as prompt-only
273
+ fallbacks. Schemas that cannot be represented in the provider's strict schema
274
+ format use prompt instructions and local validation directly. Provider/model
275
+ support varies; these request parameters have mock-based tests but have not
276
+ been verified against live APIs.
277
+
278
+ Provider parameters follow their documented APIs:
279
+ [OpenAI Chat Completions](https://developers.openai.com/api/docs/guides/structured-outputs),
280
+ [Azure OpenAI](https://learn.microsoft.com/en-us/azure/foundry/openai/how-to/structured-outputs),
281
+ [Anthropic](https://platform.claude.com/docs/en/build-with-claude/structured-outputs),
282
+ [Groq](https://console.groq.com/docs/structured-outputs),
283
+ [Google GenAI](https://ai.google.dev/gemini-api/docs/structured-output), and
284
+ [OpenRouter Chat Completions](https://openrouter.ai/docs/guides/features/structured-outputs).
285
+
248
286
  ### Streaming Events
249
287
 
250
288
  Use typed events for text, completion, and errors while keeping the legacy
@@ -260,12 +298,21 @@ for event in OpenAI().ask_stream_events("Tell me a story"):
260
298
  print("\nDone")
261
299
  ```
262
300
 
301
+ `UsageUpdate` events are emitted when an adapter receives usage in its stream.
302
+ `ToolCall` is a reserved event type and is not emitted by the current adapters.
303
+ Streaming usage extraction is mock-tested and has not been verified against
304
+ live provider APIs.
305
+
263
306
  ### 📊 Observability
264
307
 
265
- Attach optional middleware to receive provider, model, latency, and retry
266
- metadata. Provider usage extraction and failure-reason reporting are not
267
- supported yet. Estimated cost is only calculated when a response already
268
- contains usage data; the bundled provider adapters do not currently supply it:
308
+ Attach optional middleware to receive provider, model, latency, retry, and
309
+ provider-reported token usage. Usage field names are normalized to
310
+ `input_tokens`, `output_tokens`, and `total_tokens`. Estimated cost is only
311
+ calculated when usage is available and pricing for the model is supplied;
312
+ missing usage never produces a cost estimate. Mock tests cover usage extraction
313
+ for supported adapters; live provider APIs have not been verified. On failures,
314
+ middleware emits a `failed` event with `failure_reason`, then the exception is
315
+ propagated:
269
316
 
270
317
  ```python
271
318
  from ask_ai import Observability, OpenAI
@@ -273,6 +320,7 @@ from ask_ai import Observability, OpenAI
273
320
  ai = OpenAI().use(Observability(on_event=print))
274
321
  response = ai.ask("Explain tracing")
275
322
  print(response.metadata.latency_ms)
323
+ print(response.metadata.usage)
276
324
  ```
277
325
 
278
326
  ### Provider Capabilities
@@ -287,6 +335,9 @@ if capabilities.structured_output:
287
335
  print("Structured output is available")
288
336
  ```
289
337
 
338
+ Capabilities are advisory provider-level metadata, not guarantees for every
339
+ model.
340
+
290
341
  ### Smart Router
291
342
 
292
343
  Route requests across providers using deterministic `cheapest`, `fastest`, or
@@ -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.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",
@@ -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
- def __init__(self, api_key: str = None, model: str = None, persona: str = None, **kwargs):
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(self, max_messages: Optional[int] = None):
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(self, max_messages=max_messages)
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(kwargs.get('retry', final_config.extra.get('retry', 0)) or 0)
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
- yield from self._send_request_stream(messages, final_config)
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
- provider_instance = provider() if isinstance(provider, type) else provider
365
- yield from provider_instance.ask_stream_events(query, **kwargs)
366
- return
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._send_request_stream_events(messages, final_config):
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(kwargs.get('retry', final_config.extra.get('retry', 0)) or 0)
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
- async for chunk in self._send_request_stream_async(messages, final_config):
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
- 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
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._send_request_stream_events_async(messages, final_config):
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
- request_config = AdvancedConfig(**kwargs)
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 = kwargs.get('timeout', final_config.extra.get('timeout', 30))
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
- if hasattr(response_model, "model_json_schema"):
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
@@ -1,7 +1,49 @@
1
- from typing import Any, Optional
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