askai-python 0.3.3__py3-none-any.whl → 0.4.1__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 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.1"
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 AskAIError
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
- "AskAIError"
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,17 +1,24 @@
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 easyai requests.
16
+ Unified response object for all ask-ai requests.
11
17
  """
12
- def __init__(self, text: str = "", media: Union[ImageObject, AudioObject, None] = None, **kwargs):
18
+ def __init__(self, text: str = "", media: Union[ImageObject, AudioObject, None] = None, response_model: Any = None, **kwargs):
13
19
  self._raw_text = text
14
20
  self.media = media
21
+ self.response_model = response_model
15
22
  self._kwargs = kwargs
16
23
 
17
24
  @property
@@ -34,17 +41,40 @@ class Response:
34
41
  from .utils import parse_json
35
42
  return parse_json(self._raw_text)
36
43
 
37
- def __str__(self):
44
+ @property
45
+ def pydantic(self) -> Any:
46
+ """Returns parsed Pydantic model instance if response_model was provided."""
47
+ if not self.response_model:
48
+ raise ValueError("No response_model was specified for this request.")
49
+ return self.to_model(self.response_model)
50
+
51
+ def to_model(self, model_class: Any) -> Any:
52
+ """Parse the response text into a specified Pydantic model."""
53
+ text_content = self.text
54
+ if hasattr(model_class, "model_validate_json"):
55
+ return model_class.model_validate_json(text_content)
56
+ elif hasattr(model_class, "parse_raw"):
57
+ return model_class.parse_raw(text_content)
58
+ else:
59
+ import json
60
+ data = json.loads(text_content)
61
+ return model_class(**data)
62
+
63
+ def __str__(self) -> str:
38
64
  return self.text
39
65
 
40
- def save(self, path: str):
66
+ def __repr__(self) -> str:
67
+ preview = self._raw_text[:80] + "..." if len(self._raw_text) > 80 else self._raw_text
68
+ return f'<Response text="{preview}">'
69
+
70
+ def save(self, path: str) -> None:
41
71
  """Smart save based on content type."""
42
72
  if self.media:
43
73
  self.media.save(path)
44
74
  else:
45
75
  with open(path, "w", encoding="utf-8") as f:
46
76
  f.write(self.text)
47
- print(f"Text saved to {path}")
77
+
48
78
 
49
79
  class BaseProvider:
50
80
  """
@@ -52,17 +82,10 @@ class BaseProvider:
52
82
  Implements the core 'ask' logic and configuration management.
53
83
  """
54
84
  def __init__(self, api_key: str = None, model: str = None, persona: str = None, **kwargs):
55
- # 1. Zero-Config: Try env var if key not provided
85
+ # Zero-Config: Try env var if key not provided
56
86
  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
87
  self.model = model or self._get_default_model()
63
88
  self.persona = persona
64
-
65
- # Global Advanced Config
66
89
  self.config = AdvancedConfig(**kwargs)
67
90
 
68
91
  def _get_api_key_env_var(self) -> str:
@@ -73,127 +96,320 @@ class BaseProvider:
73
96
  """Subclasses should return a default model"""
74
97
  raise NotImplementedError
75
98
 
76
- def advanced(self, **kwargs):
99
+ def advanced(self, **kwargs) -> None:
77
100
  """
78
101
  Update global advanced settings for this instance.
79
102
  Merges new settings with existing ones.
80
103
  """
81
104
  new_conf = AdvancedConfig(**kwargs)
82
- # primitive merge: update self.config with new values
83
105
  for k, v in new_conf.__dict__.items():
106
+ if k == 'extra':
107
+ continue
84
108
  if v is not None:
85
109
  setattr(self.config, k, v)
86
-
87
- # Handle extra kwargs
88
110
  if new_conf.extra:
89
111
  self.config.extra.update(new_conf.extra)
90
112
 
113
+ # ─── Sync API ───────────────────────────────────────────
114
+
91
115
  def ask(self, query: str, **kwargs) -> Response:
92
116
  """
93
- The main entry point.
117
+ The main entry point. Sync version.
94
118
  Detects intent, manages config, and returns a unified Response.
95
119
  """
96
- # 1. Merge Request Config with Global Config
97
- request_config = AdvancedConfig(**kwargs)
98
- final_config = self._merge_configs(self.config, request_config)
120
+ providers = kwargs.pop("providers", None) or kwargs.pop("fallback", None)
121
+ if providers:
122
+ if not isinstance(providers, (list, tuple)):
123
+ providers = [providers]
124
+ errors = []
125
+ for p in providers:
126
+ if isinstance(p, type):
127
+ try:
128
+ p_inst = p()
129
+ except Exception as e:
130
+ errors.append(f"{p.__name__} init failed: {e}")
131
+ continue
132
+ else:
133
+ p_inst = p
134
+ try:
135
+ return p_inst.ask(query, **kwargs)
136
+ except Exception as e:
137
+ errors.append(f"{p_inst.__class__.__name__}: {e}")
138
+ logger.warning("Provider fallback failed for %s: %s", p_inst.__class__.__name__, e)
139
+ from .exceptions import ProviderError
140
+ raise ProviderError(f"All fallback providers failed: {errors}")
141
+
142
+ final_config = self._build_final_config(kwargs)
143
+ messages = self._prepare_messages(query, final_config)
144
+ output_type = kwargs.get('output_type')
145
+ retry = int(kwargs.get('retry', final_config.extra.get('retry', 0)) or 0)
146
+ response_model = kwargs.get('response_model')
147
+
148
+ response = self._execute_with_retry(
149
+ self._send_request, messages, final_config, output_type, retry
150
+ )
151
+ if response_model:
152
+ response.response_model = response_model
153
+ return response
154
+
155
+ def ask_stream(self, query: str, **kwargs) -> Iterator[str]:
156
+ """
157
+ Streaming text generation. Yields text chunks.
158
+ """
159
+ providers = kwargs.pop("providers", None) or kwargs.pop("fallback", None)
160
+ if providers:
161
+ if not isinstance(providers, (list, tuple)):
162
+ providers = [providers]
163
+ errors = []
164
+ for p in providers:
165
+ if isinstance(p, type):
166
+ try:
167
+ p_inst = p()
168
+ except Exception as e:
169
+ errors.append(f"{p.__name__} init failed: {e}")
170
+ continue
171
+ else:
172
+ p_inst = p
173
+ try:
174
+ yield from p_inst.ask_stream(query, **kwargs)
175
+ return
176
+ except Exception as e:
177
+ errors.append(f"{p_inst.__class__.__name__}: {e}")
178
+ logger.warning("Provider fallback failed for %s: %s", p_inst.__class__.__name__, e)
179
+ from .exceptions import ProviderError
180
+ raise ProviderError(f"All fallback providers failed: {errors}")
99
181
 
100
- # 2. Add System/Persona Message
182
+ final_config = self._build_final_config(kwargs)
101
183
  messages = self._prepare_messages(query, final_config)
184
+ yield from self._send_request_stream(messages, final_config)
185
+
186
+ # ─── Async API ──────────────────────────────────────────
102
187
 
103
- # 3. Check for specific output_type override (e.g. user forced image)
188
+ async def ask_async(self, query: str, **kwargs) -> Response:
189
+ """
190
+ Native async version of ask().
191
+ Uses provider's async client directly — no thread pool wrapping.
192
+ """
193
+ providers = kwargs.pop("providers", None) or kwargs.pop("fallback", None)
194
+ if providers:
195
+ if not isinstance(providers, (list, tuple)):
196
+ providers = [providers]
197
+ errors = []
198
+ for p in providers:
199
+ if isinstance(p, type):
200
+ try:
201
+ p_inst = p()
202
+ except Exception as e:
203
+ errors.append(f"{p.__name__} init failed: {e}")
204
+ continue
205
+ else:
206
+ p_inst = p
207
+ try:
208
+ return await p_inst.ask_async(query, **kwargs)
209
+ except Exception as e:
210
+ errors.append(f"{p_inst.__class__.__name__}: {e}")
211
+ logger.warning("Provider fallback failed for %s: %s", p_inst.__class__.__name__, e)
212
+ from .exceptions import ProviderError
213
+ raise ProviderError(f"All fallback providers failed: {errors}")
214
+
215
+ final_config = self._build_final_config(kwargs)
216
+ messages = self._prepare_messages(query, final_config)
104
217
  output_type = kwargs.get('output_type')
105
-
106
- # 4. Extract timeout and retry safely
107
- timeout_val = kwargs.get('timeout', final_config.extra.get('timeout', 30))
108
- retry_val = kwargs.get('retry', final_config.extra.get('retry', 0))
109
-
110
- timeout = float(timeout_val) if timeout_val is not None else 30.0
111
- retry = int(retry_val) if retry_val is not None else 0
112
-
113
- final_config.extra['timeout'] = timeout # Pass down to providers
114
-
115
- # 5. Execute with Retry Logic
218
+ retry = int(kwargs.get('retry', final_config.extra.get('retry', 0)) or 0)
219
+ response_model = kwargs.get('response_model')
220
+
221
+ response = await self._execute_with_retry_async(
222
+ self._send_request_async, messages, final_config, output_type, retry
223
+ )
224
+ if response_model:
225
+ response.response_model = response_model
226
+ return response
227
+
228
+ async def ask_stream_async(self, query: str, **kwargs) -> AsyncIterator[str]:
229
+ """
230
+ Async streaming text generation. Yields text chunks.
231
+ """
232
+ providers = kwargs.pop("providers", None) or kwargs.pop("fallback", None)
233
+ if providers:
234
+ if not isinstance(providers, (list, tuple)):
235
+ providers = [providers]
236
+ errors = []
237
+ for p in providers:
238
+ if isinstance(p, type):
239
+ try:
240
+ p_inst = p()
241
+ except Exception as e:
242
+ errors.append(f"{p.__name__} init failed: {e}")
243
+ continue
244
+ else:
245
+ p_inst = p
246
+ try:
247
+ async for chunk in await p_inst.ask_stream_async(query, **kwargs):
248
+ yield chunk
249
+ return
250
+ except Exception as e:
251
+ errors.append(f"{p_inst.__class__.__name__}: {e}")
252
+ logger.warning("Provider fallback failed for %s: %s", p_inst.__class__.__name__, e)
253
+ from .exceptions import ProviderError
254
+ raise ProviderError(f"All fallback providers failed: {errors}")
255
+
256
+ final_config = self._build_final_config(kwargs)
257
+ messages = self._prepare_messages(query, final_config)
258
+ async for chunk in self._send_request_stream_async(messages, final_config):
259
+ yield chunk
260
+
261
+ # ─── Config Helpers ─────────────────────────────────────
262
+
263
+ def _build_final_config(self, kwargs: Dict[str, Any]) -> AdvancedConfig:
264
+ """Build final config by merging global config with per-request kwargs."""
265
+ request_config = AdvancedConfig(**kwargs)
266
+ final_config = self.config.merge(request_config)
267
+
268
+ # Ensure timeout has a default
269
+ timeout = kwargs.get('timeout', final_config.extra.get('timeout', 30))
270
+ final_config.extra['timeout'] = float(timeout) if timeout is not None else 30.0
271
+
272
+ return final_config
273
+
274
+ def _prepare_messages(self, query: str, config: AdvancedConfig) -> List[Dict[str, str]]:
275
+ """Build the messages list from query, persona, and config."""
276
+ messages = []
277
+
278
+ system_msg = config.system_message or self.persona or ""
279
+
280
+ # Smart capability: if json=True or response_model is specified
281
+ response_model = config.extra.get('response_model')
282
+ if response_model:
283
+ if hasattr(response_model, "model_json_schema"):
284
+ schema = response_model.model_json_schema()
285
+ elif hasattr(response_model, "schema"):
286
+ schema = response_model.schema()
287
+ else:
288
+ schema = None
289
+
290
+ if schema:
291
+ import json
292
+ schema_str = json.dumps(schema)
293
+ json_instruction = (
294
+ f"You must respond with valid JSON matching the following JSON schema:\n"
295
+ f"{schema_str}\n"
296
+ f"Do not include any markdown markup, explanation, or HTML tags."
297
+ )
298
+ else:
299
+ json_instruction = "You must respond with valid JSON only."
300
+
301
+ system_msg = f"{system_msg}\n{json_instruction}".strip()
302
+ config.extra['json'] = True
303
+
304
+ elif config.extra.get('json'):
305
+ json_instruction = "You must respond with valid JSON only, without any markdown formatting or tags."
306
+ system_msg = f"{system_msg}\n{json_instruction}".strip()
307
+
308
+ if system_msg:
309
+ messages.append({"role": "system", "content": system_msg})
310
+
311
+ messages.append({"role": "user", "content": query})
312
+ return messages
313
+
314
+ # ─── Retry Logic ────────────────────────────────────────
315
+
316
+ def _execute_with_retry(self, func, messages, config, output_type, retry):
317
+ """Execute a sync function with exponential backoff + jitter."""
116
318
  attempts = 0
117
319
  last_error = None
320
+
118
321
  while attempts <= retry:
119
322
  try:
120
- response = self._send_request(messages, final_config, output_type)
121
- # Attach parsing arguments to the response object dynamically
122
- response._kwargs.update(kwargs)
323
+ response = func(messages, config, output_type)
123
324
  return response
124
325
  except Exception as e:
125
326
  last_error = e
126
327
  if self._is_retryable_error(e) and attempts < retry:
127
328
  attempts += 1
128
- delay = 2 ** attempts # 2s, 4s, 8s...
129
- print(f"[ask-ai] Transient error ({e.__class__.__name__}). Retrying in {delay}s... ({attempts}/{retry})")
130
- import time
329
+ delay = min(2 ** attempts + random.uniform(0, 1), 30)
330
+ logger.warning(
331
+ "Transient error (%s). Retrying in %.1fs... (%d/%d)",
332
+ e.__class__.__name__, delay, attempts, retry
333
+ )
131
334
  time.sleep(delay)
132
335
  else:
133
- raise e
134
-
135
- # Fallback if loop exits without returning
336
+ raise
337
+
136
338
  from .exceptions import AskAINetworkError
137
339
  raise AskAINetworkError(f"Request failed after {retry} retries. Last error: {last_error}")
138
340
 
139
- async def ask_async(self, query: str, **kwargs) -> Response:
140
- """
141
- Asynchronous wrapper for the .ask() method.
142
- Perfect for Telegram bots (Aiogram) and FastAPI.
143
- """
144
- import asyncio
145
- loop = asyncio.get_event_loop()
146
- def _sync_ask():
147
- return self.ask(query, **kwargs)
148
- return await loop.run_in_executor(None, _sync_ask)
341
+ async def _execute_with_retry_async(self, func, messages, config, output_type, retry):
342
+ """Execute an async function with exponential backoff + jitter."""
343
+ attempts = 0
344
+ last_error = None
345
+
346
+ while attempts <= retry:
347
+ try:
348
+ response = await func(messages, config, output_type)
349
+ return response
350
+ except Exception as e:
351
+ last_error = e
352
+ if self._is_retryable_error(e) and attempts < retry:
353
+ attempts += 1
354
+ delay = min(2 ** attempts + random.uniform(0, 1), 30)
355
+ logger.warning(
356
+ "Transient error (%s). Retrying in %.1fs... (%d/%d)",
357
+ e.__class__.__name__, delay, attempts, retry
358
+ )
359
+ await asyncio.sleep(delay)
360
+ else:
361
+ raise
362
+
363
+ from .exceptions import AskAINetworkError
364
+ raise AskAINetworkError(f"Request failed after {retry} retries. Last error: {last_error}")
149
365
 
150
366
  def _is_retryable_error(self, e: Exception) -> bool:
151
367
  """Dynamically detect rate limits and network timeouts across different provider SDKs."""
368
+ from .exceptions import AskAINetworkError, AskAIRateLimitError
369
+ if isinstance(e, (AskAINetworkError, AskAIRateLimitError)):
370
+ return True
371
+
152
372
  err_str = str(e).lower()
153
373
  err_cls = e.__class__.__name__.lower()
154
-
155
- # Explicit ask-ai errors
156
- from .exceptions import AskAINetworkError, AskAIRateLimitError
157
- if isinstance(e, (AskAINetworkError, AskAIRateLimitError)): return True
158
-
159
- # Heuristics for underlying provider SDKs (OpenAI, Anthropic, etc.)
160
- if "ratelimit" in err_cls or "rate_limit" in err_cls or "429" in err_str: return True
161
- if "timeout" in err_cls or "timeout" in err_str: return True
162
- if "connection" in err_cls or "connecterror" in err_cls: return True
163
-
164
- return False
165
-
166
- def _merge_configs(self, global_conf: AdvancedConfig, req_conf: AdvancedConfig) -> AdvancedConfig:
167
- return global_conf.merge(req_conf) # We need to implement merge logic in AdvancedConfig properly or here
168
374
 
169
- def _prepare_messages(self, query: str, config: AdvancedConfig) -> List[Dict[str, str]]:
170
- messages = []
171
-
172
- system_msg = config.system_message or self.persona or ""
173
-
174
- # Smart capability: if json=True, enforce JSON system prompt
175
- if config.extra.get('json'):
176
- json_instruction = "You must respond with valid JSON only, without any markdown formatting or tags."
177
- system_msg = f"{system_msg}\n{json_instruction}".strip()
375
+ retryable_indicators = ("ratelimit", "rate_limit", "429", "timeout", "connection", "connecterror")
376
+ return any(indicator in err_cls or indicator in err_str for indicator in retryable_indicators)
178
377
 
179
- if system_msg:
180
- messages.append({"role": "system", "content": system_msg})
181
-
182
- messages.append({"role": "user", "content": query})
183
- return messages
378
+ # ─── Abstract Methods (Subclasses Must Implement) ──────
184
379
 
185
380
  def _send_request(self, messages: List[Dict[str, str]], config: AdvancedConfig, output_type: str = None) -> Response:
186
- """Subclasses must implement this."""
381
+ """Sync request — subclasses must implement."""
187
382
  raise NotImplementedError
188
383
 
189
- # --- Tool Definitions for Smart Intent ---
384
+ async def _send_request_async(self, messages: List[Dict[str, str]], config: AdvancedConfig, output_type: str = None) -> Response:
385
+ """Async request — subclasses should override for native async."""
386
+ # Default fallback: run sync in thread pool
387
+ loop = asyncio.get_running_loop()
388
+ return await loop.run_in_executor(None, self._send_request, messages, config, output_type)
389
+
390
+ def _send_request_stream(self, messages: List[Dict[str, str]], config: AdvancedConfig) -> Iterator[str]:
391
+ """Sync streaming — subclasses should override."""
392
+ from .exceptions import StreamNotSupportedError
393
+ raise StreamNotSupportedError(f"{self.__class__.__name__} does not support streaming.")
394
+
395
+ async def _send_request_stream_async(self, messages: List[Dict[str, str]], config: AdvancedConfig) -> AsyncIterator[str]:
396
+ """Async streaming — subclasses should override for native async."""
397
+ # Default fallback: wrap sync stream
398
+ loop = asyncio.get_running_loop()
399
+ for chunk in await loop.run_in_executor(None, lambda: list(self._send_request_stream(messages, config))):
400
+ yield chunk
401
+
402
+ # ─── Tool Definitions for Smart Intent ─────────────────
403
+
190
404
  def _get_media_tools(self) -> List[Dict[str, Any]]:
191
405
  return [
192
406
  {
193
407
  "type": "function",
194
408
  "function": {
195
409
  "name": "generate_image",
196
- "description": "Generate an image based on a prompt. Use this when the user asks to draw, create, or show an image.",
410
+ "description": (
411
+ "Generate an image based on a prompt. Use this when the user asks to draw, create, or show an image."
412
+ ),
197
413
  "parameters": {
198
414
  "type": "object",
199
415
  "properties": {
@@ -210,7 +426,9 @@ class BaseProvider:
210
426
  "type": "function",
211
427
  "function": {
212
428
  "name": "generate_speech",
213
- "description": "Generate audio speech from text. Use this when the user asks to say something, speak, or read aloud.",
429
+ "description": (
430
+ "Generate audio speech from text. Use this when the user asks to say something, speak, or read aloud."
431
+ ),
214
432
  "parameters": {
215
433
  "type": "object",
216
434
  "properties": {