askai-python 0.3.3__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.3.3 → askai_python-0.4.0}/PKG-INFO +45 -17
- {askai_python-0.3.3 → askai_python-0.4.0}/README.md +20 -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.3.3 → 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.3.3 → 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.4.0/ask_ai/utils.py +68 -0
- {askai_python-0.3.3 → askai_python-0.4.0}/askai_python.egg-info/PKG-INFO +45 -17
- {askai_python-0.3.3 → askai_python-0.4.0}/askai_python.egg-info/SOURCES.txt +1 -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.3.3 → askai_python-0.4.0}/docs/PYPI_README.md +7 -7
- askai_python-0.4.0/pyproject.toml +83 -0
- {askai_python-0.3.3 → askai_python-0.4.0}/tests/test_core.py +10 -6
- askai_python-0.3.3/ask_ai/__init__.py +0 -26
- askai_python-0.3.3/ask_ai/base.py +0 -226
- askai_python-0.3.3/ask_ai/cli.py +0 -77
- askai_python-0.3.3/ask_ai/config.py +0 -56
- askai_python-0.3.3/ask_ai/media.py +0 -63
- askai_python-0.3.3/ask_ai/providers/anthropic_provider.py +0 -87
- askai_python-0.3.3/ask_ai/providers/azure_provider.py +0 -46
- askai_python-0.3.3/ask_ai/providers/google_provider.py +0 -87
- askai_python-0.3.3/ask_ai/providers/groq_provider.py +0 -75
- askai_python-0.3.3/ask_ai/providers/openai_provider.py +0 -122
- askai_python-0.3.3/ask_ai/providers/openrouter_provider.py +0 -44
- askai_python-0.3.3/ask_ai/utils.py +0 -43
- askai_python-0.3.3/askai_python.egg-info/requires.txt +0 -6
- askai_python-0.3.3/pyproject.toml +0 -33
- {askai_python-0.3.3 → askai_python-0.4.0}/LICENSE +0 -0
- {askai_python-0.3.3 → askai_python-0.4.0}/ask_ai/__main__.py +0 -0
- {askai_python-0.3.3 → askai_python-0.4.0}/ask_ai/requirements.txt +0 -0
- {askai_python-0.3.3 → askai_python-0.4.0}/askai_python.egg-info/dependency_links.txt +0 -0
- {askai_python-0.3.3 → askai_python-0.4.0}/askai_python.egg-info/top_level.txt +0 -0
- {askai_python-0.3.3 → 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
|
|
@@ -68,9 +96,9 @@ It does one thing perfectly: **Simplifying the API call to LLMs.**
|
|
|
68
96
|
### 🧰 Developer QoL Utilities (Auto-Parsing)
|
|
69
97
|
|
|
70
98
|
<p align="center">
|
|
71
|
-
<img src="https://raw.githubusercontent.com/Hosseinghorbani0/
|
|
99
|
+
<img src="https://raw.githubusercontent.com/Hosseinghorbani0/askai-python/main/assets/features.svg" alt="askai-python features parsing banner" width="100%">
|
|
72
100
|
</p>
|
|
73
|
-
Stop writing Regex to clean up model outputs! `
|
|
101
|
+
Stop writing Regex to clean up model outputs! `askai-python` comes with built-in text processing flags:
|
|
74
102
|
|
|
75
103
|
```python
|
|
76
104
|
from ask_ai import OpenAI
|
|
@@ -97,7 +125,7 @@ print(data_dict['name'])
|
|
|
97
125
|
## 🚀 Built-in Resiliency (Retries & Timeouts)
|
|
98
126
|
|
|
99
127
|
<p align="center">
|
|
100
|
-
<img src="https://raw.githubusercontent.com/Hosseinghorbani0/
|
|
128
|
+
<img src="https://raw.githubusercontent.com/Hosseinghorbani0/askai-python/main/assets/resiliency.svg" alt="askai-python resiliency banner" width="100%">
|
|
101
129
|
</p>
|
|
102
130
|
|
|
103
131
|
Build reliable apps without writing your own loops. `askai-python` handles rate limits (`429`) and network drops via an internal exponential backoff.
|
|
@@ -140,6 +168,6 @@ response = ai.ask(
|
|
|
140
168
|
|
|
141
169
|
## 🔗 Important Links
|
|
142
170
|
|
|
143
|
-
- **GitHub Repository**: [Hosseinghorbani0/
|
|
171
|
+
- **GitHub Repository**: [Hosseinghorbani0/askai-python](https://github.com/Hosseinghorbani0/askai-python) (Star us! ⭐)
|
|
144
172
|
- **Official Website**: [hosseinghorbani0.ir](https://hosseinghorbani0.ir/)
|
|
145
|
-
- **Bug Tracker**: [Report an Issue](https://github.com/Hosseinghorbani0/
|
|
173
|
+
- **Bug Tracker**: [Report an Issue](https://github.com/Hosseinghorbani0/askai-python/issues)
|
|
@@ -125,12 +125,31 @@ print(ai.ask("How do I optimize a Dockerfile?").text)
|
|
|
125
125
|
|
|
126
126
|
---
|
|
127
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
|
+
|
|
128
147
|
## 🗺️ Roadmap
|
|
129
148
|
|
|
130
149
|
- [x] OpenAI, Groq, Gemini, Anthropic, Azure, OpenRouter support
|
|
131
150
|
- [x] Text to Image (DALL-E) and Text to Audio
|
|
132
151
|
- [x] Automated Retry & Timeout controls
|
|
133
|
-
- [
|
|
152
|
+
- [x] Async Support (`await ask_async`)
|
|
134
153
|
- [ ] Provider Fallback (`ask(..., providers=[OpenAI, Groq])`)
|
|
135
154
|
- [ ] Streaming Support (`ask_stream`)
|
|
136
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()
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from typing import Optional, Dict, Any, List
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AdvancedConfig:
|
|
5
|
+
"""
|
|
6
|
+
Configuration class for advanced AI settings.
|
|
7
|
+
Supports both direct init and aliased parameters for usability.
|
|
8
|
+
"""
|
|
9
|
+
def __init__(
|
|
10
|
+
self,
|
|
11
|
+
temperature: Optional[float] = None,
|
|
12
|
+
max_tokens: Optional[int] = None,
|
|
13
|
+
top_p: Optional[float] = None,
|
|
14
|
+
frequency_penalty: Optional[float] = None,
|
|
15
|
+
presence_penalty: Optional[float] = None,
|
|
16
|
+
stop: Optional[List[str]] = None,
|
|
17
|
+
system_message: Optional[str] = None,
|
|
18
|
+
safe_mode: bool = False,
|
|
19
|
+
**kwargs
|
|
20
|
+
):
|
|
21
|
+
self.temperature = temperature
|
|
22
|
+
self.max_tokens = max_tokens
|
|
23
|
+
self.top_p = top_p
|
|
24
|
+
self.frequency_penalty = frequency_penalty
|
|
25
|
+
self.presence_penalty = presence_penalty
|
|
26
|
+
self.stop = stop
|
|
27
|
+
|
|
28
|
+
# Smart Aliases for 10/10 Usability
|
|
29
|
+
# If user passes 'prompt' or 'system', treat it as 'system_message'
|
|
30
|
+
if system_message is None:
|
|
31
|
+
system_message = kwargs.pop("prompt", None) or kwargs.pop("system", None)
|
|
32
|
+
else:
|
|
33
|
+
# Remove aliases from kwargs if system_message was explicitly set
|
|
34
|
+
kwargs.pop("prompt", None)
|
|
35
|
+
kwargs.pop("system", None)
|
|
36
|
+
|
|
37
|
+
self.system_message = system_message
|
|
38
|
+
self.safe_mode = safe_mode
|
|
39
|
+
self.extra: Dict[str, Any] = kwargs
|
|
40
|
+
|
|
41
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
42
|
+
"""Convert config to a dictionary, filtering None values."""
|
|
43
|
+
result = {}
|
|
44
|
+
for k, v in self.__dict__.items():
|
|
45
|
+
if k == 'extra':
|
|
46
|
+
continue
|
|
47
|
+
if v is not None:
|
|
48
|
+
result[k] = v
|
|
49
|
+
return result
|
|
50
|
+
|
|
51
|
+
def merge(self, other: 'AdvancedConfig') -> 'AdvancedConfig':
|
|
52
|
+
"""
|
|
53
|
+
Merge another config into this one (other overrides self).
|
|
54
|
+
Returns a new AdvancedConfig without mutating either input.
|
|
55
|
+
"""
|
|
56
|
+
merged = AdvancedConfig()
|
|
57
|
+
|
|
58
|
+
# Copy self values
|
|
59
|
+
for k, v in self.__dict__.items():
|
|
60
|
+
if k == 'extra':
|
|
61
|
+
continue
|
|
62
|
+
setattr(merged, k, v)
|
|
63
|
+
|
|
64
|
+
# Override with other values (only non-None)
|
|
65
|
+
for k, v in other.__dict__.items():
|
|
66
|
+
if k == 'extra':
|
|
67
|
+
continue
|
|
68
|
+
if v is not None:
|
|
69
|
+
setattr(merged, k, v)
|
|
70
|
+
|
|
71
|
+
# Deep merge extra dicts
|
|
72
|
+
merged_extra = dict(self.extra)
|
|
73
|
+
merged_extra.update(other.extra)
|
|
74
|
+
merged.extra = merged_extra
|
|
75
|
+
|
|
76
|
+
return merged
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def from_dict(cls, data: Dict[str, Any]) -> 'AdvancedConfig':
|
|
80
|
+
"""Create an AdvancedConfig from a dictionary."""
|
|
81
|
+
return cls(**data)
|