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.
- {askai_python-0.5.0 → askai_python-0.6.0}/PKG-INFO +29 -10
- {askai_python-0.5.0 → askai_python-0.6.0}/README.md +57 -6
- {askai_python-0.5.0 → askai_python-0.6.0}/ask_ai/__init__.py +3 -1
- {askai_python-0.5.0 → askai_python-0.6.0}/ask_ai/base.py +201 -26
- {askai_python-0.5.0 → askai_python-0.6.0}/ask_ai/config.py +49 -1
- {askai_python-0.5.0 → askai_python-0.6.0}/ask_ai/exceptions.py +17 -2
- {askai_python-0.5.0 → askai_python-0.6.0}/ask_ai/media_security.py +50 -3
- {askai_python-0.5.0 → askai_python-0.6.0}/ask_ai/observability.py +31 -0
- {askai_python-0.5.0 → askai_python-0.6.0}/ask_ai/providers/anthropic_provider.py +84 -4
- {askai_python-0.5.0 → askai_python-0.6.0}/ask_ai/providers/azure_provider.py +9 -2
- askai_python-0.6.0/ask_ai/providers/google_provider.py +255 -0
- {askai_python-0.5.0 → askai_python-0.6.0}/ask_ai/providers/groq_provider.py +109 -6
- {askai_python-0.5.0 → askai_python-0.6.0}/ask_ai/providers/openai_provider.py +118 -15
- {askai_python-0.5.0 → askai_python-0.6.0}/ask_ai/providers/openrouter_provider.py +10 -2
- askai_python-0.6.0/ask_ai/py.typed +0 -0
- {askai_python-0.5.0 → askai_python-0.6.0}/ask_ai/requirements.txt +2 -2
- {askai_python-0.5.0 → askai_python-0.6.0}/ask_ai/session.py +32 -6
- askai_python-0.6.0/ask_ai/structured.py +309 -0
- {askai_python-0.5.0 → askai_python-0.6.0}/askai_python.egg-info/PKG-INFO +29 -10
- {askai_python-0.5.0 → askai_python-0.6.0}/askai_python.egg-info/SOURCES.txt +9 -1
- {askai_python-0.5.0 → askai_python-0.6.0}/askai_python.egg-info/requires.txt +4 -4
- {askai_python-0.5.0 → askai_python-0.6.0}/docs/PYPI_README.md +23 -3
- {askai_python-0.5.0 → askai_python-0.6.0}/pyproject.toml +9 -9
- askai_python-0.6.0/tests/test_core.py +691 -0
- {askai_python-0.5.0 → askai_python-0.6.0}/tests/test_gitignore.py +8 -0
- askai_python-0.6.0/tests/test_google_genai.py +189 -0
- {askai_python-0.5.0 → askai_python-0.6.0}/tests/test_media_security.py +72 -0
- askai_python-0.6.0/tests/test_native_structured.py +471 -0
- askai_python-0.6.0/tests/test_options_validation.py +136 -0
- askai_python-0.6.0/tests/test_packaging.py +8 -0
- askai_python-0.6.0/tests/test_release_docs.py +46 -0
- {askai_python-0.5.0 → askai_python-0.6.0}/tests/test_session.py +48 -0
- askai_python-0.6.0/tests/test_smoke_real.py +63 -0
- {askai_python-0.5.0 → askai_python-0.6.0}/tests/test_streaming_events.py +63 -0
- {askai_python-0.5.0 → askai_python-0.6.0}/tests/test_structured.py +2 -2
- askai_python-0.6.0/tests/test_usage.py +359 -0
- askai_python-0.5.0/ask_ai/providers/google_provider.py +0 -163
- askai_python-0.5.0/ask_ai/structured.py +0 -88
- askai_python-0.5.0/tests/test_core.py +0 -266
- {askai_python-0.5.0 → askai_python-0.6.0}/LICENSE +0 -0
- {askai_python-0.5.0 → askai_python-0.6.0}/ask_ai/__main__.py +0 -0
- {askai_python-0.5.0 → askai_python-0.6.0}/ask_ai/capabilities.py +0 -0
- {askai_python-0.5.0 → askai_python-0.6.0}/ask_ai/cli.py +0 -0
- {askai_python-0.5.0 → askai_python-0.6.0}/ask_ai/events.py +0 -0
- {askai_python-0.5.0 → askai_python-0.6.0}/ask_ai/media.py +0 -0
- {askai_python-0.5.0 → askai_python-0.6.0}/ask_ai/providers/__init__.py +0 -0
- {askai_python-0.5.0 → askai_python-0.6.0}/ask_ai/router.py +0 -0
- {askai_python-0.5.0 → askai_python-0.6.0}/ask_ai/utils.py +0 -0
- {askai_python-0.5.0 → askai_python-0.6.0}/askai_python.egg-info/dependency_links.txt +0 -0
- {askai_python-0.5.0 → askai_python-0.6.0}/askai_python.egg-info/entry_points.txt +0 -0
- {askai_python-0.5.0 → askai_python-0.6.0}/askai_python.egg-info/top_level.txt +0 -0
- {askai_python-0.5.0 → askai_python-0.6.0}/setup.cfg +0 -0
- {askai_python-0.5.0 → askai_python-0.6.0}/tests/test_capabilities.py +0 -0
- {askai_python-0.5.0 → askai_python-0.6.0}/tests/test_observability.py +0 -0
- {askai_python-0.5.0 → askai_python-0.6.0}/tests/test_openrouter_release.py +0 -0
- {askai_python-0.5.0 → askai_python-0.6.0}/tests/test_python39_compat.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
29
|
+
Requires-Dist: anthropic>=1.0.0; extra == "anthropic"
|
|
31
30
|
Provides-Extra: google
|
|
32
|
-
Requires-Dist: 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.
|
|
45
|
-
Requires-Dist: google-
|
|
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,
|
|
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
|
[](https://pypi.org/project/askai-python/)
|
|
27
27
|
[](https://opensource.org/licenses/MIT)
|
|
28
|
-
[](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
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
+
__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
|
-
|
|
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
|
|
@@ -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
|