askai-python 0.2.1__py3-none-any.whl → 0.4.0__py3-none-any.whl
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.
- ask_ai/__init__.py +27 -2
- ask_ai/base.py +162 -75
- ask_ai/cli.py +83 -53
- ask_ai/config.py +45 -20
- ask_ai/exceptions.py +13 -1
- ask_ai/media.py +53 -26
- ask_ai/providers/__init__.py +1 -4
- ask_ai/providers/anthropic_provider.py +96 -55
- ask_ai/providers/azure_provider.py +39 -18
- ask_ai/providers/google_provider.py +114 -51
- ask_ai/providers/groq_provider.py +87 -48
- ask_ai/providers/openai_provider.py +197 -62
- ask_ai/providers/openrouter_provider.py +38 -16
- ask_ai/requirements.txt +5 -0
- ask_ai/utils.py +40 -15
- {askai_python-0.2.1.dist-info → askai_python-0.4.0.dist-info}/METADATA +51 -15
- askai_python-0.4.0.dist-info/RECORD +22 -0
- {askai_python-0.2.1.dist-info → askai_python-0.4.0.dist-info}/WHEEL +1 -1
- askai_python-0.4.0.dist-info/entry_points.txt +2 -0
- askai_python-0.2.1.dist-info/RECORD +0 -20
- {askai_python-0.2.1.dist-info → askai_python-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {askai_python-0.2.1.dist-info → askai_python-0.4.0.dist-info}/top_level.txt +0 -0
ask_ai/__init__.py
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ask-ai: A minimal Python SDK to switch between LLM providers in one line.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
__version__ = "0.4.0"
|
|
6
|
+
|
|
1
7
|
from .providers import (
|
|
2
8
|
OpenAI,
|
|
3
9
|
Anthropic,
|
|
@@ -9,18 +15,37 @@ from .providers import (
|
|
|
9
15
|
from .config import AdvancedConfig
|
|
10
16
|
from .base import Response
|
|
11
17
|
from .media import ImageObject, AudioObject
|
|
12
|
-
from .exceptions import
|
|
18
|
+
from .exceptions import (
|
|
19
|
+
AskAIError,
|
|
20
|
+
AskAIConfigError,
|
|
21
|
+
AskAINetworkError,
|
|
22
|
+
AskAIRateLimitError,
|
|
23
|
+
AskAIParsingError,
|
|
24
|
+
ProviderError,
|
|
25
|
+
MediaTypeNotSupportedError,
|
|
26
|
+
StreamNotSupportedError,
|
|
27
|
+
)
|
|
13
28
|
|
|
14
29
|
__all__ = [
|
|
30
|
+
# Providers
|
|
15
31
|
"OpenAI",
|
|
16
32
|
"Anthropic",
|
|
17
33
|
"Google",
|
|
18
34
|
"Groq",
|
|
19
35
|
"Azure",
|
|
20
36
|
"OpenRouter",
|
|
37
|
+
# Core
|
|
21
38
|
"AdvancedConfig",
|
|
22
39
|
"Response",
|
|
23
40
|
"ImageObject",
|
|
24
41
|
"AudioObject",
|
|
25
|
-
|
|
42
|
+
# Exceptions
|
|
43
|
+
"AskAIError",
|
|
44
|
+
"AskAIConfigError",
|
|
45
|
+
"AskAINetworkError",
|
|
46
|
+
"AskAIRateLimitError",
|
|
47
|
+
"AskAIParsingError",
|
|
48
|
+
"ProviderError",
|
|
49
|
+
"MediaTypeNotSupportedError",
|
|
50
|
+
"StreamNotSupportedError",
|
|
26
51
|
]
|
ask_ai/base.py
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
|
-
from typing import Optional, Union, List, Dict, Any
|
|
2
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
|
+
|
|
3
8
|
from .config import AdvancedConfig
|
|
4
9
|
from .media import ImageObject, AudioObject
|
|
5
10
|
|
|
11
|
+
logger = logging.getLogger("ask_ai")
|
|
6
12
|
|
|
7
13
|
|
|
8
14
|
class Response:
|
|
9
15
|
"""
|
|
10
|
-
Unified response object for all
|
|
16
|
+
Unified response object for all ask-ai requests.
|
|
11
17
|
"""
|
|
12
18
|
def __init__(self, text: str = "", media: Union[ImageObject, AudioObject, None] = None, **kwargs):
|
|
13
19
|
self._raw_text = text
|
|
@@ -34,17 +40,21 @@ class Response:
|
|
|
34
40
|
from .utils import parse_json
|
|
35
41
|
return parse_json(self._raw_text)
|
|
36
42
|
|
|
37
|
-
def __str__(self):
|
|
43
|
+
def __str__(self) -> str:
|
|
38
44
|
return self.text
|
|
39
45
|
|
|
40
|
-
def
|
|
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:
|
|
41
51
|
"""Smart save based on content type."""
|
|
42
52
|
if self.media:
|
|
43
53
|
self.media.save(path)
|
|
44
54
|
else:
|
|
45
55
|
with open(path, "w", encoding="utf-8") as f:
|
|
46
56
|
f.write(self.text)
|
|
47
|
-
|
|
57
|
+
|
|
48
58
|
|
|
49
59
|
class BaseProvider:
|
|
50
60
|
"""
|
|
@@ -52,17 +62,10 @@ class BaseProvider:
|
|
|
52
62
|
Implements the core 'ask' logic and configuration management.
|
|
53
63
|
"""
|
|
54
64
|
def __init__(self, api_key: str = None, model: str = None, persona: str = None, **kwargs):
|
|
55
|
-
#
|
|
65
|
+
# Zero-Config: Try env var if key not provided
|
|
56
66
|
self.api_key = api_key or os.environ.get(self._get_api_key_env_var())
|
|
57
|
-
if not self.api_key:
|
|
58
|
-
# Some providers might not need it (e.g. local), but generally they do.
|
|
59
|
-
# We allow subclass to handle granular validation, but warn here.
|
|
60
|
-
pass
|
|
61
|
-
|
|
62
67
|
self.model = model or self._get_default_model()
|
|
63
68
|
self.persona = persona
|
|
64
|
-
|
|
65
|
-
# Global Advanced Config
|
|
66
69
|
self.config = AdvancedConfig(**kwargs)
|
|
67
70
|
|
|
68
71
|
def _get_api_key_env_var(self) -> str:
|
|
@@ -73,116 +76,198 @@ class BaseProvider:
|
|
|
73
76
|
"""Subclasses should return a default model"""
|
|
74
77
|
raise NotImplementedError
|
|
75
78
|
|
|
76
|
-
def advanced(self, **kwargs):
|
|
79
|
+
def advanced(self, **kwargs) -> None:
|
|
77
80
|
"""
|
|
78
81
|
Update global advanced settings for this instance.
|
|
79
82
|
Merges new settings with existing ones.
|
|
80
83
|
"""
|
|
81
84
|
new_conf = AdvancedConfig(**kwargs)
|
|
82
|
-
# primitive merge: update self.config with new values
|
|
83
85
|
for k, v in new_conf.__dict__.items():
|
|
86
|
+
if k == 'extra':
|
|
87
|
+
continue
|
|
84
88
|
if v is not None:
|
|
85
89
|
setattr(self.config, k, v)
|
|
86
|
-
|
|
87
|
-
# Handle extra kwargs
|
|
88
90
|
if new_conf.extra:
|
|
89
91
|
self.config.extra.update(new_conf.extra)
|
|
90
92
|
|
|
93
|
+
# ─── Sync API ───────────────────────────────────────────
|
|
94
|
+
|
|
91
95
|
def ask(self, query: str, **kwargs) -> Response:
|
|
92
96
|
"""
|
|
93
|
-
The main entry point.
|
|
97
|
+
The main entry point. Sync version.
|
|
94
98
|
Detects intent, manages config, and returns a unified Response.
|
|
95
99
|
"""
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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)
|
|
99
104
|
|
|
100
|
-
|
|
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)
|
|
101
114
|
messages = self._prepare_messages(query, final_config)
|
|
115
|
+
yield from self._send_request_stream(messages, final_config)
|
|
116
|
+
|
|
117
|
+
# ─── Async API ──────────────────────────────────────────
|
|
102
118
|
|
|
103
|
-
|
|
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)
|
|
104
126
|
output_type = kwargs.get('output_type')
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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."""
|
|
116
176
|
attempts = 0
|
|
117
177
|
last_error = None
|
|
178
|
+
|
|
118
179
|
while attempts <= retry:
|
|
119
180
|
try:
|
|
120
|
-
response =
|
|
121
|
-
# Attach parsing arguments to the response object dynamically
|
|
122
|
-
response._kwargs.update(kwargs)
|
|
181
|
+
response = func(messages, config, output_type)
|
|
123
182
|
return response
|
|
124
183
|
except Exception as e:
|
|
125
184
|
last_error = e
|
|
126
185
|
if self._is_retryable_error(e) and attempts < retry:
|
|
127
186
|
attempts += 1
|
|
128
|
-
delay = 2 ** attempts
|
|
129
|
-
|
|
130
|
-
|
|
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
|
+
)
|
|
131
192
|
time.sleep(delay)
|
|
132
193
|
else:
|
|
133
|
-
raise
|
|
134
|
-
|
|
135
|
-
|
|
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
|
+
|
|
136
221
|
from .exceptions import AskAINetworkError
|
|
137
222
|
raise AskAINetworkError(f"Request failed after {retry} retries. Last error: {last_error}")
|
|
138
223
|
|
|
139
224
|
def _is_retryable_error(self, e: Exception) -> bool:
|
|
140
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
|
+
|
|
141
230
|
err_str = str(e).lower()
|
|
142
231
|
err_cls = e.__class__.__name__.lower()
|
|
143
|
-
|
|
144
|
-
# Explicit ask-ai errors
|
|
145
|
-
from .exceptions import AskAINetworkError, AskAIRateLimitError
|
|
146
|
-
if isinstance(e, (AskAINetworkError, AskAIRateLimitError)): return True
|
|
147
|
-
|
|
148
|
-
# Heuristics for underlying provider SDKs (OpenAI, Anthropic, etc.)
|
|
149
|
-
if "ratelimit" in err_cls or "rate_limit" in err_cls or "429" in err_str: return True
|
|
150
|
-
if "timeout" in err_cls or "timeout" in err_str: return True
|
|
151
|
-
if "connection" in err_cls or "connecterror" in err_cls: return True
|
|
152
|
-
|
|
153
|
-
return False
|
|
154
|
-
|
|
155
|
-
def _merge_configs(self, global_conf: AdvancedConfig, req_conf: AdvancedConfig) -> AdvancedConfig:
|
|
156
|
-
return global_conf.merge(req_conf) # We need to implement merge logic in AdvancedConfig properly or here
|
|
157
232
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
system_msg = config.system_message or self.persona or ""
|
|
162
|
-
|
|
163
|
-
# Smart capability: if json=True, enforce JSON system prompt
|
|
164
|
-
if config.extra.get('json'):
|
|
165
|
-
json_instruction = "You must respond with valid JSON only, without any markdown formatting or tags."
|
|
166
|
-
system_msg = f"{system_msg}\n{json_instruction}".strip()
|
|
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)
|
|
167
235
|
|
|
168
|
-
|
|
169
|
-
messages.append({"role": "system", "content": system_msg})
|
|
170
|
-
|
|
171
|
-
messages.append({"role": "user", "content": query})
|
|
172
|
-
return messages
|
|
236
|
+
# ─── Abstract Methods (Subclasses Must Implement) ──────
|
|
173
237
|
|
|
174
238
|
def _send_request(self, messages: List[Dict[str, str]], config: AdvancedConfig, output_type: str = None) -> Response:
|
|
175
|
-
"""
|
|
239
|
+
"""Sync request — subclasses must implement."""
|
|
176
240
|
raise NotImplementedError
|
|
177
241
|
|
|
178
|
-
|
|
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
|
+
|
|
179
262
|
def _get_media_tools(self) -> List[Dict[str, Any]]:
|
|
180
263
|
return [
|
|
181
264
|
{
|
|
182
265
|
"type": "function",
|
|
183
266
|
"function": {
|
|
184
267
|
"name": "generate_image",
|
|
185
|
-
"description":
|
|
268
|
+
"description": (
|
|
269
|
+
"Generate an image based on a prompt. Use this when the user asks to draw, create, or show an image."
|
|
270
|
+
),
|
|
186
271
|
"parameters": {
|
|
187
272
|
"type": "object",
|
|
188
273
|
"properties": {
|
|
@@ -199,7 +284,9 @@ class BaseProvider:
|
|
|
199
284
|
"type": "function",
|
|
200
285
|
"function": {
|
|
201
286
|
"name": "generate_speech",
|
|
202
|
-
"description":
|
|
287
|
+
"description": (
|
|
288
|
+
"Generate audio speech from text. Use this when the user asks to say something, speak, or read aloud."
|
|
289
|
+
),
|
|
203
290
|
"parameters": {
|
|
204
291
|
"type": "object",
|
|
205
292
|
"properties": {
|
ask_ai/cli.py
CHANGED
|
@@ -1,77 +1,107 @@
|
|
|
1
1
|
import sys
|
|
2
2
|
import argparse
|
|
3
|
-
from .
|
|
4
|
-
from .
|
|
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
|
+
|
|
5
24
|
|
|
6
25
|
def main():
|
|
7
|
-
parser = argparse.ArgumentParser(
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
parser.add_argument("
|
|
11
|
-
parser.add_argument(
|
|
12
|
-
|
|
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
|
+
|
|
13
43
|
args = parser.parse_args()
|
|
14
44
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
# Lazy load others to avoid unnecessary imports if cli is used often?
|
|
19
|
-
# But we already imported OpenAI.
|
|
20
|
-
# Let's import dynamically or just map if already imported in __init__.
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
# We need to import others dynamically or statically.
|
|
24
|
-
# Since __init__ exposes them, let's use that.
|
|
25
|
-
import easyai
|
|
26
|
-
|
|
27
|
-
provider_class = getattr(easyai, args.provider.capitalize(), None)
|
|
28
|
-
if args.provider == "openrouter": # naming case mismatch
|
|
29
|
-
provider_class = easyai.OpenRouter
|
|
30
|
-
elif args.provider == "openai":
|
|
31
|
-
provider_class = easyai.OpenAI
|
|
32
|
-
elif args.provider == "groq":
|
|
33
|
-
provider_class = easyai.Groq
|
|
34
|
-
elif args.provider == "google":
|
|
35
|
-
provider_class = easyai.Google
|
|
36
|
-
elif args.provider == "azure":
|
|
37
|
-
provider_class = easyai.Azure
|
|
38
|
-
elif args.provider == "anthropic":
|
|
39
|
-
provider_class = easyai.Anthropic
|
|
45
|
+
if not args.query:
|
|
46
|
+
parser.print_help()
|
|
47
|
+
sys.exit(0)
|
|
40
48
|
|
|
49
|
+
provider_class = PROVIDER_MAP.get(args.provider.lower())
|
|
41
50
|
if not provider_class:
|
|
42
51
|
print(f"Error: Provider '{args.provider}' not found.")
|
|
43
52
|
sys.exit(1)
|
|
44
53
|
|
|
45
54
|
try:
|
|
46
55
|
# Init provider
|
|
47
|
-
|
|
56
|
+
init_kwargs = {}
|
|
48
57
|
if args.model:
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
ai = provider_class(**
|
|
52
|
-
|
|
53
|
-
#
|
|
58
|
+
init_kwargs["model"] = args.model
|
|
59
|
+
|
|
60
|
+
ai = provider_class(**init_kwargs)
|
|
61
|
+
|
|
62
|
+
# Build ask kwargs
|
|
54
63
|
ask_kwargs = {}
|
|
55
64
|
if args.temp is not None:
|
|
56
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
|
|
57
70
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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)
|
|
68
81
|
else:
|
|
69
|
-
|
|
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)
|
|
70
94
|
|
|
71
|
-
except
|
|
72
|
-
print(f"
|
|
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)
|
|
73
101
|
except Exception as e:
|
|
74
|
-
print(f"Unexpected Error: {e}")
|
|
102
|
+
print(f"Unexpected Error: {e}", file=sys.stderr)
|
|
103
|
+
sys.exit(1)
|
|
104
|
+
|
|
75
105
|
|
|
76
106
|
if __name__ == "__main__":
|
|
77
107
|
main()
|
ask_ai/config.py
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
from typing import Optional, Dict, Any
|
|
1
|
+
from typing import Optional, Dict, Any, List
|
|
2
|
+
|
|
2
3
|
|
|
3
4
|
class AdvancedConfig:
|
|
4
5
|
"""
|
|
5
6
|
Configuration class for advanced AI settings.
|
|
7
|
+
Supports both direct init and aliased parameters for usability.
|
|
6
8
|
"""
|
|
7
9
|
def __init__(
|
|
8
10
|
self,
|
|
@@ -11,7 +13,7 @@ class AdvancedConfig:
|
|
|
11
13
|
top_p: Optional[float] = None,
|
|
12
14
|
frequency_penalty: Optional[float] = None,
|
|
13
15
|
presence_penalty: Optional[float] = None,
|
|
14
|
-
stop: Optional[
|
|
16
|
+
stop: Optional[List[str]] = None,
|
|
15
17
|
system_message: Optional[str] = None,
|
|
16
18
|
safe_mode: bool = False,
|
|
17
19
|
**kwargs
|
|
@@ -22,35 +24,58 @@ class AdvancedConfig:
|
|
|
22
24
|
self.frequency_penalty = frequency_penalty
|
|
23
25
|
self.presence_penalty = presence_penalty
|
|
24
26
|
self.stop = stop
|
|
25
|
-
|
|
26
|
-
|
|
27
|
+
|
|
27
28
|
# Smart Aliases for 10/10 Usability
|
|
28
29
|
# If user passes 'prompt' or 'system', treat it as 'system_message'
|
|
29
30
|
if system_message is None:
|
|
30
|
-
system_message = kwargs.
|
|
31
|
-
|
|
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
|
+
|
|
32
37
|
self.system_message = system_message
|
|
33
38
|
self.safe_mode = safe_mode
|
|
34
|
-
self.extra = kwargs
|
|
39
|
+
self.extra: Dict[str, Any] = kwargs
|
|
35
40
|
|
|
36
41
|
def to_dict(self) -> Dict[str, Any]:
|
|
37
42
|
"""Convert config to a dictionary, filtering None values."""
|
|
38
|
-
|
|
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
|
|
39
50
|
|
|
40
|
-
def merge(self,
|
|
41
|
-
"""
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
44
59
|
for k, v in self.__dict__.items():
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
49
68
|
if v is not None:
|
|
50
|
-
setattr(
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
+
|
|
54
78
|
@classmethod
|
|
55
79
|
def from_dict(cls, data: Dict[str, Any]) -> 'AdvancedConfig':
|
|
80
|
+
"""Create an AdvancedConfig from a dictionary."""
|
|
56
81
|
return cls(**data)
|