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.
Files changed (42) hide show
  1. {askai_python-0.2.1 → askai_python-0.4.0}/PKG-INFO +51 -15
  2. {askai_python-0.2.1 → askai_python-0.4.0}/README.md +35 -1
  3. askai_python-0.4.0/ask_ai/__init__.py +51 -0
  4. askai_python-0.4.0/ask_ai/base.py +302 -0
  5. askai_python-0.4.0/ask_ai/cli.py +107 -0
  6. askai_python-0.4.0/ask_ai/config.py +81 -0
  7. {askai_python-0.2.1 → askai_python-0.4.0}/ask_ai/exceptions.py +13 -1
  8. askai_python-0.4.0/ask_ai/media.py +90 -0
  9. {askai_python-0.2.1 → askai_python-0.4.0}/ask_ai/providers/__init__.py +1 -4
  10. askai_python-0.4.0/ask_ai/providers/anthropic_provider.py +128 -0
  11. askai_python-0.4.0/ask_ai/providers/azure_provider.py +67 -0
  12. askai_python-0.4.0/ask_ai/providers/google_provider.py +150 -0
  13. askai_python-0.4.0/ask_ai/providers/groq_provider.py +114 -0
  14. askai_python-0.4.0/ask_ai/providers/openai_provider.py +257 -0
  15. askai_python-0.4.0/ask_ai/providers/openrouter_provider.py +66 -0
  16. askai_python-0.2.1/askai_python.egg-info/requires.txt → askai_python-0.4.0/ask_ai/requirements.txt +0 -1
  17. askai_python-0.4.0/ask_ai/utils.py +68 -0
  18. {askai_python-0.2.1 → askai_python-0.4.0}/askai_python.egg-info/PKG-INFO +51 -15
  19. {askai_python-0.2.1 → askai_python-0.4.0}/askai_python.egg-info/SOURCES.txt +2 -0
  20. askai_python-0.4.0/askai_python.egg-info/entry_points.txt +2 -0
  21. askai_python-0.4.0/askai_python.egg-info/requires.txt +26 -0
  22. {askai_python-0.2.1 → askai_python-0.4.0}/docs/PYPI_README.md +13 -5
  23. askai_python-0.4.0/pyproject.toml +83 -0
  24. {askai_python-0.2.1 → askai_python-0.4.0}/tests/test_core.py +11 -8
  25. askai_python-0.2.1/ask_ai/__init__.py +0 -26
  26. askai_python-0.2.1/ask_ai/base.py +0 -215
  27. askai_python-0.2.1/ask_ai/cli.py +0 -77
  28. askai_python-0.2.1/ask_ai/config.py +0 -56
  29. askai_python-0.2.1/ask_ai/media.py +0 -63
  30. askai_python-0.2.1/ask_ai/providers/anthropic_provider.py +0 -87
  31. askai_python-0.2.1/ask_ai/providers/azure_provider.py +0 -46
  32. askai_python-0.2.1/ask_ai/providers/google_provider.py +0 -87
  33. askai_python-0.2.1/ask_ai/providers/groq_provider.py +0 -75
  34. askai_python-0.2.1/ask_ai/providers/openai_provider.py +0 -122
  35. askai_python-0.2.1/ask_ai/providers/openrouter_provider.py +0 -44
  36. askai_python-0.2.1/ask_ai/utils.py +0 -43
  37. askai_python-0.2.1/pyproject.toml +0 -26
  38. {askai_python-0.2.1 → askai_python-0.4.0}/LICENSE +0 -0
  39. {askai_python-0.2.1 → askai_python-0.4.0}/ask_ai/__main__.py +0 -0
  40. {askai_python-0.2.1 → askai_python-0.4.0}/askai_python.egg-info/dependency_links.txt +0 -0
  41. {askai_python-0.2.1 → askai_python-0.4.0}/askai_python.egg-info/top_level.txt +0 -0
  42. {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.2.1
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://hosseinghorbani0.ir/
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
- Requires-Python: >=3.8
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
- Requires-Dist: openai>=1.0.0
13
- Requires-Dist: groq
14
- Requires-Dist: google-generativeai
15
- Requires-Dist: anthropic
16
- Requires-Dist: requests
17
- Requires-Dist: Pillow
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
  [![PyPI version](https://img.shields.io/pypi/v/askai-python.svg)](https://pypi.org/project/askai-python/)
26
54
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
27
- [![GitHub](https://img.shields.io/badge/GitHub-View%20Source-blue.svg)](https://github.com/Hosseinghorbani0/ask-ai)
55
+ [![GitHub](https://img.shields.io/badge/GitHub-View%20Source-blue.svg)](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 ask-ai?
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
- Stop writing Regex to clean up model outputs! `ask-ai` comes with built-in text processing flags:
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/ask-ai](https://github.com/Hosseinghorbani0/ask-ai) (Star us! ⭐)
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/ask-ai/issues)
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
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
22
27
  [![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](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
- - [ ] Async Support (`await ask_async`)
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()