askai-python 0.2.1__tar.gz → 0.4.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.2.1 → askai_python-0.4.0}/PKG-INFO +51 -15
- {askai_python-0.2.1 → askai_python-0.4.0}/README.md +35 -1
- askai_python-0.4.0/ask_ai/__init__.py +51 -0
- askai_python-0.4.0/ask_ai/base.py +302 -0
- askai_python-0.4.0/ask_ai/cli.py +107 -0
- askai_python-0.4.0/ask_ai/config.py +81 -0
- {askai_python-0.2.1 → askai_python-0.4.0}/ask_ai/exceptions.py +13 -1
- askai_python-0.4.0/ask_ai/media.py +90 -0
- {askai_python-0.2.1 → askai_python-0.4.0}/ask_ai/providers/__init__.py +1 -4
- askai_python-0.4.0/ask_ai/providers/anthropic_provider.py +128 -0
- askai_python-0.4.0/ask_ai/providers/azure_provider.py +67 -0
- askai_python-0.4.0/ask_ai/providers/google_provider.py +150 -0
- askai_python-0.4.0/ask_ai/providers/groq_provider.py +114 -0
- askai_python-0.4.0/ask_ai/providers/openai_provider.py +257 -0
- askai_python-0.4.0/ask_ai/providers/openrouter_provider.py +66 -0
- askai_python-0.2.1/askai_python.egg-info/requires.txt → askai_python-0.4.0/ask_ai/requirements.txt +0 -1
- askai_python-0.4.0/ask_ai/utils.py +68 -0
- {askai_python-0.2.1 → askai_python-0.4.0}/askai_python.egg-info/PKG-INFO +51 -15
- {askai_python-0.2.1 → askai_python-0.4.0}/askai_python.egg-info/SOURCES.txt +2 -0
- askai_python-0.4.0/askai_python.egg-info/entry_points.txt +2 -0
- askai_python-0.4.0/askai_python.egg-info/requires.txt +26 -0
- {askai_python-0.2.1 → askai_python-0.4.0}/docs/PYPI_README.md +13 -5
- askai_python-0.4.0/pyproject.toml +83 -0
- {askai_python-0.2.1 → askai_python-0.4.0}/tests/test_core.py +11 -8
- askai_python-0.2.1/ask_ai/__init__.py +0 -26
- askai_python-0.2.1/ask_ai/base.py +0 -215
- askai_python-0.2.1/ask_ai/cli.py +0 -77
- askai_python-0.2.1/ask_ai/config.py +0 -56
- askai_python-0.2.1/ask_ai/media.py +0 -63
- askai_python-0.2.1/ask_ai/providers/anthropic_provider.py +0 -87
- askai_python-0.2.1/ask_ai/providers/azure_provider.py +0 -46
- askai_python-0.2.1/ask_ai/providers/google_provider.py +0 -87
- askai_python-0.2.1/ask_ai/providers/groq_provider.py +0 -75
- askai_python-0.2.1/ask_ai/providers/openai_provider.py +0 -122
- askai_python-0.2.1/ask_ai/providers/openrouter_provider.py +0 -44
- askai_python-0.2.1/ask_ai/utils.py +0 -43
- askai_python-0.2.1/pyproject.toml +0 -26
- {askai_python-0.2.1 → askai_python-0.4.0}/LICENSE +0 -0
- {askai_python-0.2.1 → askai_python-0.4.0}/ask_ai/__main__.py +0 -0
- {askai_python-0.2.1 → askai_python-0.4.0}/askai_python.egg-info/dependency_links.txt +0 -0
- {askai_python-0.2.1 → askai_python-0.4.0}/askai_python.egg-info/top_level.txt +0 -0
- {askai_python-0.2.1 → askai_python-0.4.0}/setup.cfg +0 -0
|
@@ -1,20 +1,48 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: askai-python
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: AI Made Stupid Simple. Unified API for OpenAI, Groq, Google, and more.
|
|
3
|
+
Version: 0.4.0
|
|
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
|
|
7
|
-
Project-URL: Homepage, https://
|
|
7
|
+
Project-URL: Homepage, https://github.com/Hosseinghorbani0/ask-ai
|
|
8
|
+
Project-URL: Documentation, https://github.com/Hosseinghorbani0/ask-ai#readme
|
|
8
9
|
Project-URL: Bug Tracker, https://github.com/Hosseinghorbani0/ask-ai/issues
|
|
9
|
-
|
|
10
|
+
Project-URL: Changelog, https://github.com/Hosseinghorbani0/ask-ai/releases
|
|
11
|
+
Keywords: ai,llm,openai,anthropic,gemini,groq,sdk,api
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.9
|
|
10
25
|
Description-Content-Type: text/markdown
|
|
11
26
|
License-File: LICENSE
|
|
12
|
-
|
|
13
|
-
Requires-Dist:
|
|
14
|
-
|
|
15
|
-
Requires-Dist: anthropic
|
|
16
|
-
|
|
17
|
-
Requires-Dist:
|
|
27
|
+
Provides-Extra: openai
|
|
28
|
+
Requires-Dist: openai>=1.0.0; extra == "openai"
|
|
29
|
+
Provides-Extra: anthropic
|
|
30
|
+
Requires-Dist: anthropic>=0.20.0; extra == "anthropic"
|
|
31
|
+
Provides-Extra: google
|
|
32
|
+
Requires-Dist: google-generativeai>=0.5.0; extra == "google"
|
|
33
|
+
Provides-Extra: groq
|
|
34
|
+
Requires-Dist: groq>=0.4.0; extra == "groq"
|
|
35
|
+
Provides-Extra: media
|
|
36
|
+
Requires-Dist: Pillow>=9.0.0; extra == "media"
|
|
37
|
+
Provides-Extra: httpx
|
|
38
|
+
Requires-Dist: httpx>=0.24.0; extra == "httpx"
|
|
39
|
+
Provides-Extra: all
|
|
40
|
+
Requires-Dist: openai>=1.0.0; extra == "all"
|
|
41
|
+
Requires-Dist: anthropic>=0.20.0; extra == "all"
|
|
42
|
+
Requires-Dist: google-generativeai>=0.5.0; extra == "all"
|
|
43
|
+
Requires-Dist: groq>=0.4.0; extra == "all"
|
|
44
|
+
Requires-Dist: Pillow>=9.0.0; extra == "all"
|
|
45
|
+
Requires-Dist: httpx>=0.24.0; extra == "all"
|
|
18
46
|
Dynamic: license-file
|
|
19
47
|
|
|
20
48
|
# askai-python 🚀
|
|
@@ -24,7 +52,7 @@ No frameworks. No servers. No overengineering.
|
|
|
24
52
|
|
|
25
53
|
[](https://pypi.org/project/askai-python/)
|
|
26
54
|
[](https://opensource.org/licenses/MIT)
|
|
27
|
-
[](https://github.com/Hosseinghorbani0/
|
|
55
|
+
[](https://github.com/Hosseinghorbani0/askai-python)
|
|
28
56
|
|
|
29
57
|
---
|
|
30
58
|
|
|
@@ -46,7 +74,7 @@ print(Groq().ask("Explain black holes like I'm 5").text)
|
|
|
46
74
|
|
|
47
75
|
---
|
|
48
76
|
|
|
49
|
-
## 🧐 Why
|
|
77
|
+
## 🧐 Why askai-python?
|
|
50
78
|
|
|
51
79
|
- **One function**: Just call `.ask()`
|
|
52
80
|
- **Multiple providers**: OpenAI, Anthropic, Google Gemini, Groq, Azure, OpenRouter
|
|
@@ -66,7 +94,11 @@ It does one thing perfectly: **Simplifying the API call to LLMs.**
|
|
|
66
94
|
## 🛠️ Advanced Usage
|
|
67
95
|
|
|
68
96
|
### 🧰 Developer QoL Utilities (Auto-Parsing)
|
|
69
|
-
|
|
97
|
+
|
|
98
|
+
<p align="center">
|
|
99
|
+
<img src="https://raw.githubusercontent.com/Hosseinghorbani0/askai-python/main/assets/features.svg" alt="askai-python features parsing banner" width="100%">
|
|
100
|
+
</p>
|
|
101
|
+
Stop writing Regex to clean up model outputs! `askai-python` comes with built-in text processing flags:
|
|
70
102
|
|
|
71
103
|
```python
|
|
72
104
|
from ask_ai import OpenAI
|
|
@@ -92,6 +124,10 @@ print(data_dict['name'])
|
|
|
92
124
|
|
|
93
125
|
## 🚀 Built-in Resiliency (Retries & Timeouts)
|
|
94
126
|
|
|
127
|
+
<p align="center">
|
|
128
|
+
<img src="https://raw.githubusercontent.com/Hosseinghorbani0/askai-python/main/assets/resiliency.svg" alt="askai-python resiliency banner" width="100%">
|
|
129
|
+
</p>
|
|
130
|
+
|
|
95
131
|
Build reliable apps without writing your own loops. `askai-python` handles rate limits (`429`) and network drops via an internal exponential backoff.
|
|
96
132
|
|
|
97
133
|
```python
|
|
@@ -132,6 +168,6 @@ response = ai.ask(
|
|
|
132
168
|
|
|
133
169
|
## 🔗 Important Links
|
|
134
170
|
|
|
135
|
-
- **GitHub Repository**: [Hosseinghorbani0/
|
|
171
|
+
- **GitHub Repository**: [Hosseinghorbani0/askai-python](https://github.com/Hosseinghorbani0/askai-python) (Star us! ⭐)
|
|
136
172
|
- **Official Website**: [hosseinghorbani0.ir](https://hosseinghorbani0.ir/)
|
|
137
|
-
- **Bug Tracker**: [Report an Issue](https://github.com/Hosseinghorbani0/
|
|
173
|
+
- **Bug Tracker**: [Report an Issue](https://github.com/Hosseinghorbani0/askai-python/issues)
|
|
@@ -12,6 +12,11 @@
|
|
|
12
12
|
<a href="docs/README_ja.md"><img src="https://flagcdn.com/20x15/jp.png" alt="Japanese"> 日本語</a>
|
|
13
13
|
</p>
|
|
14
14
|
|
|
15
|
+
|
|
16
|
+
<p align="center">
|
|
17
|
+
<img src="assets/banner.svg" alt="ask-ai animated banner" width="100%">
|
|
18
|
+
</p>
|
|
19
|
+
|
|
15
20
|
<p align="center">
|
|
16
21
|
<b>A minimal Python SDK to switch between LLM providers in one line.</b><br/>
|
|
17
22
|
No frameworks. No servers. No overengineering.
|
|
@@ -21,6 +26,8 @@
|
|
|
21
26
|
[](https://opensource.org/licenses/MIT)
|
|
22
27
|
[](https://www.python.org/downloads/)
|
|
23
28
|
|
|
29
|
+
|
|
30
|
+
|
|
24
31
|
---
|
|
25
32
|
|
|
26
33
|
## ⚡ Quick Start (5 seconds)
|
|
@@ -61,6 +68,10 @@ It does one thing perfectly: **Simplifying the API call to LLMs.**
|
|
|
61
68
|
## 🛠️ Advanced Usage
|
|
62
69
|
|
|
63
70
|
### 🧰 Developer QoL Utilities (Auto-Parsing)
|
|
71
|
+
|
|
72
|
+
<p align="center">
|
|
73
|
+
<img src="assets/features.svg" alt="ask-ai features parsing banner" width="100%">
|
|
74
|
+
</p>
|
|
64
75
|
Stop writing Regex to clean up model outputs! `ask-ai` comes with built-in text processing flags:
|
|
65
76
|
|
|
66
77
|
```python
|
|
@@ -86,6 +97,10 @@ print(data_dict['name'])
|
|
|
86
97
|
```
|
|
87
98
|
|
|
88
99
|
### 🔄 Built-in Retries & Timeout (Resiliency)
|
|
100
|
+
|
|
101
|
+
<p align="center">
|
|
102
|
+
<img src="assets/resiliency.svg" alt="ask-ai resiliency banner" width="100%">
|
|
103
|
+
</p>
|
|
89
104
|
Handle rate limits (`429`) and network drops automatically:
|
|
90
105
|
|
|
91
106
|
```python
|
|
@@ -110,12 +125,31 @@ print(ai.ask("How do I optimize a Dockerfile?").text)
|
|
|
110
125
|
|
|
111
126
|
---
|
|
112
127
|
|
|
128
|
+
### 🚀 Async Support (FastAPI / Aiogram)
|
|
129
|
+
|
|
130
|
+
`ask-ai` works perfectly in asynchronous environments:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
import asyncio
|
|
134
|
+
from ask_ai import OpenAI
|
|
135
|
+
|
|
136
|
+
async def main():
|
|
137
|
+
ai = OpenAI()
|
|
138
|
+
# Runs non-blocking in your event loop
|
|
139
|
+
response = await ai.ask_async("What is the capital of France?")
|
|
140
|
+
print(response.text)
|
|
141
|
+
|
|
142
|
+
asyncio.run(main())
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
113
147
|
## 🗺️ Roadmap
|
|
114
148
|
|
|
115
149
|
- [x] OpenAI, Groq, Gemini, Anthropic, Azure, OpenRouter support
|
|
116
150
|
- [x] Text to Image (DALL-E) and Text to Audio
|
|
117
151
|
- [x] Automated Retry & Timeout controls
|
|
118
|
-
- [
|
|
152
|
+
- [x] Async Support (`await ask_async`)
|
|
119
153
|
- [ ] Provider Fallback (`ask(..., providers=[OpenAI, Groq])`)
|
|
120
154
|
- [ ] Streaming Support (`ask_stream`)
|
|
121
155
|
- [ ] Structured Output (Pydantic Support)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ask-ai: A minimal Python SDK to switch between LLM providers in one line.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
__version__ = "0.4.0"
|
|
6
|
+
|
|
7
|
+
from .providers import (
|
|
8
|
+
OpenAI,
|
|
9
|
+
Anthropic,
|
|
10
|
+
Google,
|
|
11
|
+
Groq,
|
|
12
|
+
Azure,
|
|
13
|
+
OpenRouter
|
|
14
|
+
)
|
|
15
|
+
from .config import AdvancedConfig
|
|
16
|
+
from .base import Response
|
|
17
|
+
from .media import ImageObject, AudioObject
|
|
18
|
+
from .exceptions import (
|
|
19
|
+
AskAIError,
|
|
20
|
+
AskAIConfigError,
|
|
21
|
+
AskAINetworkError,
|
|
22
|
+
AskAIRateLimitError,
|
|
23
|
+
AskAIParsingError,
|
|
24
|
+
ProviderError,
|
|
25
|
+
MediaTypeNotSupportedError,
|
|
26
|
+
StreamNotSupportedError,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
# Providers
|
|
31
|
+
"OpenAI",
|
|
32
|
+
"Anthropic",
|
|
33
|
+
"Google",
|
|
34
|
+
"Groq",
|
|
35
|
+
"Azure",
|
|
36
|
+
"OpenRouter",
|
|
37
|
+
# Core
|
|
38
|
+
"AdvancedConfig",
|
|
39
|
+
"Response",
|
|
40
|
+
"ImageObject",
|
|
41
|
+
"AudioObject",
|
|
42
|
+
# Exceptions
|
|
43
|
+
"AskAIError",
|
|
44
|
+
"AskAIConfigError",
|
|
45
|
+
"AskAINetworkError",
|
|
46
|
+
"AskAIRateLimitError",
|
|
47
|
+
"AskAIParsingError",
|
|
48
|
+
"ProviderError",
|
|
49
|
+
"MediaTypeNotSupportedError",
|
|
50
|
+
"StreamNotSupportedError",
|
|
51
|
+
]
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
import random
|
|
4
|
+
import asyncio
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Union, List, Dict, Any, Iterator, AsyncIterator
|
|
7
|
+
|
|
8
|
+
from .config import AdvancedConfig
|
|
9
|
+
from .media import ImageObject, AudioObject
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("ask_ai")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Response:
|
|
15
|
+
"""
|
|
16
|
+
Unified response object for all ask-ai requests.
|
|
17
|
+
"""
|
|
18
|
+
def __init__(self, text: str = "", media: Union[ImageObject, AudioObject, None] = None, **kwargs):
|
|
19
|
+
self._raw_text = text
|
|
20
|
+
self.media = media
|
|
21
|
+
self._kwargs = kwargs
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def text(self) -> str:
|
|
25
|
+
text = self._raw_text
|
|
26
|
+
if self._kwargs.get('strip'):
|
|
27
|
+
from .utils import strip_tags
|
|
28
|
+
text = strip_tags(text)
|
|
29
|
+
if self._kwargs.get('clean'):
|
|
30
|
+
from .utils import clean_markdown
|
|
31
|
+
text = clean_markdown(text)
|
|
32
|
+
if self._kwargs.get('code'):
|
|
33
|
+
from .utils import extract_code
|
|
34
|
+
text = extract_code(text)
|
|
35
|
+
return text
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def json(self) -> Union[Dict[str, Any], list]:
|
|
39
|
+
"""Returns parsed JSON. If 'json=True' was passed, this is highly reliable."""
|
|
40
|
+
from .utils import parse_json
|
|
41
|
+
return parse_json(self._raw_text)
|
|
42
|
+
|
|
43
|
+
def __str__(self) -> str:
|
|
44
|
+
return self.text
|
|
45
|
+
|
|
46
|
+
def __repr__(self) -> str:
|
|
47
|
+
preview = self._raw_text[:80] + "..." if len(self._raw_text) > 80 else self._raw_text
|
|
48
|
+
return f'<Response text="{preview}">'
|
|
49
|
+
|
|
50
|
+
def save(self, path: str) -> None:
|
|
51
|
+
"""Smart save based on content type."""
|
|
52
|
+
if self.media:
|
|
53
|
+
self.media.save(path)
|
|
54
|
+
else:
|
|
55
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
56
|
+
f.write(self.text)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class BaseProvider:
|
|
60
|
+
"""
|
|
61
|
+
Abstract base class for all AI providers.
|
|
62
|
+
Implements the core 'ask' logic and configuration management.
|
|
63
|
+
"""
|
|
64
|
+
def __init__(self, api_key: str = None, model: str = None, persona: str = None, **kwargs):
|
|
65
|
+
# Zero-Config: Try env var if key not provided
|
|
66
|
+
self.api_key = api_key or os.environ.get(self._get_api_key_env_var())
|
|
67
|
+
self.model = model or self._get_default_model()
|
|
68
|
+
self.persona = persona
|
|
69
|
+
self.config = AdvancedConfig(**kwargs)
|
|
70
|
+
|
|
71
|
+
def _get_api_key_env_var(self) -> str:
|
|
72
|
+
"""Subclasses should return the env var name, e.g. 'OPENAI_API_KEY'"""
|
|
73
|
+
raise NotImplementedError
|
|
74
|
+
|
|
75
|
+
def _get_default_model(self) -> str:
|
|
76
|
+
"""Subclasses should return a default model"""
|
|
77
|
+
raise NotImplementedError
|
|
78
|
+
|
|
79
|
+
def advanced(self, **kwargs) -> None:
|
|
80
|
+
"""
|
|
81
|
+
Update global advanced settings for this instance.
|
|
82
|
+
Merges new settings with existing ones.
|
|
83
|
+
"""
|
|
84
|
+
new_conf = AdvancedConfig(**kwargs)
|
|
85
|
+
for k, v in new_conf.__dict__.items():
|
|
86
|
+
if k == 'extra':
|
|
87
|
+
continue
|
|
88
|
+
if v is not None:
|
|
89
|
+
setattr(self.config, k, v)
|
|
90
|
+
if new_conf.extra:
|
|
91
|
+
self.config.extra.update(new_conf.extra)
|
|
92
|
+
|
|
93
|
+
# ─── Sync API ───────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
def ask(self, query: str, **kwargs) -> Response:
|
|
96
|
+
"""
|
|
97
|
+
The main entry point. Sync version.
|
|
98
|
+
Detects intent, manages config, and returns a unified Response.
|
|
99
|
+
"""
|
|
100
|
+
final_config = self._build_final_config(kwargs)
|
|
101
|
+
messages = self._prepare_messages(query, final_config)
|
|
102
|
+
output_type = kwargs.get('output_type')
|
|
103
|
+
retry = int(kwargs.get('retry', final_config.extra.get('retry', 0)) or 0)
|
|
104
|
+
|
|
105
|
+
return self._execute_with_retry(
|
|
106
|
+
self._send_request, messages, final_config, output_type, retry
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def ask_stream(self, query: str, **kwargs) -> Iterator[str]:
|
|
110
|
+
"""
|
|
111
|
+
Streaming text generation. Yields text chunks.
|
|
112
|
+
"""
|
|
113
|
+
final_config = self._build_final_config(kwargs)
|
|
114
|
+
messages = self._prepare_messages(query, final_config)
|
|
115
|
+
yield from self._send_request_stream(messages, final_config)
|
|
116
|
+
|
|
117
|
+
# ─── Async API ──────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
async def ask_async(self, query: str, **kwargs) -> Response:
|
|
120
|
+
"""
|
|
121
|
+
Native async version of ask().
|
|
122
|
+
Uses provider's async client directly — no thread pool wrapping.
|
|
123
|
+
"""
|
|
124
|
+
final_config = self._build_final_config(kwargs)
|
|
125
|
+
messages = self._prepare_messages(query, final_config)
|
|
126
|
+
output_type = kwargs.get('output_type')
|
|
127
|
+
retry = int(kwargs.get('retry', final_config.extra.get('retry', 0)) or 0)
|
|
128
|
+
|
|
129
|
+
return await self._execute_with_retry_async(
|
|
130
|
+
self._send_request_async, messages, final_config, output_type, retry
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
async def ask_stream_async(self, query: str, **kwargs) -> AsyncIterator[str]:
|
|
134
|
+
"""
|
|
135
|
+
Async streaming text generation. Yields text chunks.
|
|
136
|
+
"""
|
|
137
|
+
final_config = self._build_final_config(kwargs)
|
|
138
|
+
messages = self._prepare_messages(query, final_config)
|
|
139
|
+
async for chunk in self._send_request_stream_async(messages, final_config):
|
|
140
|
+
yield chunk
|
|
141
|
+
|
|
142
|
+
# ─── Config Helpers ─────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
def _build_final_config(self, kwargs: Dict[str, Any]) -> AdvancedConfig:
|
|
145
|
+
"""Build final config by merging global config with per-request kwargs."""
|
|
146
|
+
request_config = AdvancedConfig(**kwargs)
|
|
147
|
+
final_config = self.config.merge(request_config)
|
|
148
|
+
|
|
149
|
+
# Ensure timeout has a default
|
|
150
|
+
timeout = kwargs.get('timeout', final_config.extra.get('timeout', 30))
|
|
151
|
+
final_config.extra['timeout'] = float(timeout) if timeout is not None else 30.0
|
|
152
|
+
|
|
153
|
+
return final_config
|
|
154
|
+
|
|
155
|
+
def _prepare_messages(self, query: str, config: AdvancedConfig) -> List[Dict[str, str]]:
|
|
156
|
+
"""Build the messages list from query, persona, and config."""
|
|
157
|
+
messages = []
|
|
158
|
+
|
|
159
|
+
system_msg = config.system_message or self.persona or ""
|
|
160
|
+
|
|
161
|
+
# Smart capability: if json=True, enforce JSON system prompt
|
|
162
|
+
if config.extra.get('json'):
|
|
163
|
+
json_instruction = "You must respond with valid JSON only, without any markdown formatting or tags."
|
|
164
|
+
system_msg = f"{system_msg}\n{json_instruction}".strip()
|
|
165
|
+
|
|
166
|
+
if system_msg:
|
|
167
|
+
messages.append({"role": "system", "content": system_msg})
|
|
168
|
+
|
|
169
|
+
messages.append({"role": "user", "content": query})
|
|
170
|
+
return messages
|
|
171
|
+
|
|
172
|
+
# ─── Retry Logic ────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
def _execute_with_retry(self, func, messages, config, output_type, retry):
|
|
175
|
+
"""Execute a sync function with exponential backoff + jitter."""
|
|
176
|
+
attempts = 0
|
|
177
|
+
last_error = None
|
|
178
|
+
|
|
179
|
+
while attempts <= retry:
|
|
180
|
+
try:
|
|
181
|
+
response = func(messages, config, output_type)
|
|
182
|
+
return response
|
|
183
|
+
except Exception as e:
|
|
184
|
+
last_error = e
|
|
185
|
+
if self._is_retryable_error(e) and attempts < retry:
|
|
186
|
+
attempts += 1
|
|
187
|
+
delay = min(2 ** attempts + random.uniform(0, 1), 30)
|
|
188
|
+
logger.warning(
|
|
189
|
+
"Transient error (%s). Retrying in %.1fs... (%d/%d)",
|
|
190
|
+
e.__class__.__name__, delay, attempts, retry
|
|
191
|
+
)
|
|
192
|
+
time.sleep(delay)
|
|
193
|
+
else:
|
|
194
|
+
raise
|
|
195
|
+
|
|
196
|
+
from .exceptions import AskAINetworkError
|
|
197
|
+
raise AskAINetworkError(f"Request failed after {retry} retries. Last error: {last_error}")
|
|
198
|
+
|
|
199
|
+
async def _execute_with_retry_async(self, func, messages, config, output_type, retry):
|
|
200
|
+
"""Execute an async function with exponential backoff + jitter."""
|
|
201
|
+
attempts = 0
|
|
202
|
+
last_error = None
|
|
203
|
+
|
|
204
|
+
while attempts <= retry:
|
|
205
|
+
try:
|
|
206
|
+
response = await func(messages, config, output_type)
|
|
207
|
+
return response
|
|
208
|
+
except Exception as e:
|
|
209
|
+
last_error = e
|
|
210
|
+
if self._is_retryable_error(e) and attempts < retry:
|
|
211
|
+
attempts += 1
|
|
212
|
+
delay = min(2 ** attempts + random.uniform(0, 1), 30)
|
|
213
|
+
logger.warning(
|
|
214
|
+
"Transient error (%s). Retrying in %.1fs... (%d/%d)",
|
|
215
|
+
e.__class__.__name__, delay, attempts, retry
|
|
216
|
+
)
|
|
217
|
+
await asyncio.sleep(delay)
|
|
218
|
+
else:
|
|
219
|
+
raise
|
|
220
|
+
|
|
221
|
+
from .exceptions import AskAINetworkError
|
|
222
|
+
raise AskAINetworkError(f"Request failed after {retry} retries. Last error: {last_error}")
|
|
223
|
+
|
|
224
|
+
def _is_retryable_error(self, e: Exception) -> bool:
|
|
225
|
+
"""Dynamically detect rate limits and network timeouts across different provider SDKs."""
|
|
226
|
+
from .exceptions import AskAINetworkError, AskAIRateLimitError
|
|
227
|
+
if isinstance(e, (AskAINetworkError, AskAIRateLimitError)):
|
|
228
|
+
return True
|
|
229
|
+
|
|
230
|
+
err_str = str(e).lower()
|
|
231
|
+
err_cls = e.__class__.__name__.lower()
|
|
232
|
+
|
|
233
|
+
retryable_indicators = ("ratelimit", "rate_limit", "429", "timeout", "connection", "connecterror")
|
|
234
|
+
return any(indicator in err_cls or indicator in err_str for indicator in retryable_indicators)
|
|
235
|
+
|
|
236
|
+
# ─── Abstract Methods (Subclasses Must Implement) ──────
|
|
237
|
+
|
|
238
|
+
def _send_request(self, messages: List[Dict[str, str]], config: AdvancedConfig, output_type: str = None) -> Response:
|
|
239
|
+
"""Sync request — subclasses must implement."""
|
|
240
|
+
raise NotImplementedError
|
|
241
|
+
|
|
242
|
+
async def _send_request_async(self, messages: List[Dict[str, str]], config: AdvancedConfig, output_type: str = None) -> Response:
|
|
243
|
+
"""Async request — subclasses should override for native async."""
|
|
244
|
+
# Default fallback: run sync in thread pool
|
|
245
|
+
loop = asyncio.get_running_loop()
|
|
246
|
+
return await loop.run_in_executor(None, self._send_request, messages, config, output_type)
|
|
247
|
+
|
|
248
|
+
def _send_request_stream(self, messages: List[Dict[str, str]], config: AdvancedConfig) -> Iterator[str]:
|
|
249
|
+
"""Sync streaming — subclasses should override."""
|
|
250
|
+
from .exceptions import StreamNotSupportedError
|
|
251
|
+
raise StreamNotSupportedError(f"{self.__class__.__name__} does not support streaming.")
|
|
252
|
+
|
|
253
|
+
async def _send_request_stream_async(self, messages: List[Dict[str, str]], config: AdvancedConfig) -> AsyncIterator[str]:
|
|
254
|
+
"""Async streaming — subclasses should override for native async."""
|
|
255
|
+
# Default fallback: wrap sync stream
|
|
256
|
+
loop = asyncio.get_running_loop()
|
|
257
|
+
for chunk in await loop.run_in_executor(None, lambda: list(self._send_request_stream(messages, config))):
|
|
258
|
+
yield chunk
|
|
259
|
+
|
|
260
|
+
# ─── Tool Definitions for Smart Intent ─────────────────
|
|
261
|
+
|
|
262
|
+
def _get_media_tools(self) -> List[Dict[str, Any]]:
|
|
263
|
+
return [
|
|
264
|
+
{
|
|
265
|
+
"type": "function",
|
|
266
|
+
"function": {
|
|
267
|
+
"name": "generate_image",
|
|
268
|
+
"description": (
|
|
269
|
+
"Generate an image based on a prompt. Use this when the user asks to draw, create, or show an image."
|
|
270
|
+
),
|
|
271
|
+
"parameters": {
|
|
272
|
+
"type": "object",
|
|
273
|
+
"properties": {
|
|
274
|
+
"prompt": {
|
|
275
|
+
"type": "string",
|
|
276
|
+
"description": "The detailed description of the image to generate."
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
"required": ["prompt"]
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
"type": "function",
|
|
285
|
+
"function": {
|
|
286
|
+
"name": "generate_speech",
|
|
287
|
+
"description": (
|
|
288
|
+
"Generate audio speech from text. Use this when the user asks to say something, speak, or read aloud."
|
|
289
|
+
),
|
|
290
|
+
"parameters": {
|
|
291
|
+
"type": "object",
|
|
292
|
+
"properties": {
|
|
293
|
+
"text": {
|
|
294
|
+
"type": "string",
|
|
295
|
+
"description": "The text to speak."
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
"required": ["text"]
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
]
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import argparse
|
|
3
|
+
from . import __version__
|
|
4
|
+
from . import (
|
|
5
|
+
OpenAI,
|
|
6
|
+
Groq,
|
|
7
|
+
Google,
|
|
8
|
+
OpenRouter,
|
|
9
|
+
Azure,
|
|
10
|
+
Anthropic,
|
|
11
|
+
AskAIError
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
PROVIDER_MAP = {
|
|
16
|
+
"openai": OpenAI,
|
|
17
|
+
"groq": Groq,
|
|
18
|
+
"google": Google,
|
|
19
|
+
"openrouter": OpenRouter,
|
|
20
|
+
"azure": Azure,
|
|
21
|
+
"anthropic": Anthropic,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main():
|
|
26
|
+
parser = argparse.ArgumentParser(
|
|
27
|
+
description="ask-ai CLI: Ask AI anything directly from your terminal."
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument("query", nargs="?", help="The text query to ask the AI.")
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--provider", "-p",
|
|
32
|
+
default="openai",
|
|
33
|
+
choices=list(PROVIDER_MAP.keys()),
|
|
34
|
+
help="The AI provider to use (default: openai)."
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument("--model", "-m", help="Specific model to use.")
|
|
37
|
+
parser.add_argument("--temp", type=float, help="Temperature setting.")
|
|
38
|
+
parser.add_argument("--system", "-s", help="System prompt.")
|
|
39
|
+
parser.add_argument("--json", action="store_true", help="Request JSON output.")
|
|
40
|
+
parser.add_argument("--stream", action="store_true", help="Enable streaming output.")
|
|
41
|
+
parser.add_argument("--version", "-v", action="version", version=f"ask-ai {__version__}")
|
|
42
|
+
|
|
43
|
+
args = parser.parse_args()
|
|
44
|
+
|
|
45
|
+
if not args.query:
|
|
46
|
+
parser.print_help()
|
|
47
|
+
sys.exit(0)
|
|
48
|
+
|
|
49
|
+
provider_class = PROVIDER_MAP.get(args.provider.lower())
|
|
50
|
+
if not provider_class:
|
|
51
|
+
print(f"Error: Provider '{args.provider}' not found.")
|
|
52
|
+
sys.exit(1)
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
# Init provider
|
|
56
|
+
init_kwargs = {}
|
|
57
|
+
if args.model:
|
|
58
|
+
init_kwargs["model"] = args.model
|
|
59
|
+
|
|
60
|
+
ai = provider_class(**init_kwargs)
|
|
61
|
+
|
|
62
|
+
# Build ask kwargs
|
|
63
|
+
ask_kwargs = {}
|
|
64
|
+
if args.temp is not None:
|
|
65
|
+
ask_kwargs["temperature"] = args.temp
|
|
66
|
+
if args.system:
|
|
67
|
+
ask_kwargs["system_message"] = args.system
|
|
68
|
+
if args.json:
|
|
69
|
+
ask_kwargs["json"] = True
|
|
70
|
+
|
|
71
|
+
# Stream or regular
|
|
72
|
+
if args.stream:
|
|
73
|
+
try:
|
|
74
|
+
for chunk in ai.ask_stream(args.query, **ask_kwargs):
|
|
75
|
+
print(chunk, end="", flush=True)
|
|
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)
|
|
81
|
+
else:
|
|
82
|
+
response = ai.ask(args.query, **ask_kwargs)
|
|
83
|
+
|
|
84
|
+
if response.media:
|
|
85
|
+
print(f"Generated Media: {response.media.type}")
|
|
86
|
+
filename = f"output.{'png' if response.media.type == 'image' else 'mp3'}"
|
|
87
|
+
response.media.save(filename)
|
|
88
|
+
print(f"Saved to {filename}")
|
|
89
|
+
elif args.json:
|
|
90
|
+
import json
|
|
91
|
+
print(json.dumps(response.json, indent=2, ensure_ascii=False))
|
|
92
|
+
else:
|
|
93
|
+
print(response.text)
|
|
94
|
+
|
|
95
|
+
except AskAIError as e:
|
|
96
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
97
|
+
sys.exit(1)
|
|
98
|
+
except KeyboardInterrupt:
|
|
99
|
+
print("\nInterrupted.")
|
|
100
|
+
sys.exit(130)
|
|
101
|
+
except Exception as e:
|
|
102
|
+
print(f"Unexpected Error: {e}", file=sys.stderr)
|
|
103
|
+
sys.exit(1)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
main()
|