askai-python 0.4.1__py3-none-any.whl → 0.5.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 CHANGED
@@ -2,29 +2,36 @@
2
2
  ask-ai: A minimal Python SDK to switch between LLM providers in one line.
3
3
  """
4
4
 
5
- __version__ = "0.4.1"
5
+ __version__ = "0.5.0"
6
6
 
7
- from .providers import (
8
- OpenAI,
9
- Anthropic,
10
- Google,
11
- Groq,
12
- Azure,
13
- OpenRouter
14
- )
15
- from .config import AdvancedConfig
16
7
  from .base import Response
17
- from .media import ImageObject, AudioObject
8
+ from .capabilities import Capabilities
9
+ from .config import AdvancedConfig
10
+ from .events import Completed, Error, TextDelta, ToolCall, UsageUpdate
18
11
  from .exceptions import (
19
- AskAIError,
20
12
  AskAIConfigError,
13
+ AskAIError,
21
14
  AskAINetworkError,
22
- AskAIRateLimitError,
23
15
  AskAIParsingError,
24
- ProviderError,
16
+ AskAIRateLimitError,
17
+ MediaSecurityError,
25
18
  MediaTypeNotSupportedError,
19
+ ProviderAuthError,
20
+ ProviderError,
21
+ ProviderInvalidRequestError,
22
+ ProviderRateLimitError,
23
+ ProviderTimeoutError,
24
+ ProviderUnavailableError,
26
25
  StreamNotSupportedError,
26
+ StructuredValidationError,
27
27
  )
28
+ from .media import AudioObject, ImageObject
29
+ from .media_security import MediaPolicy, download_media
30
+ from .observability import Observability, ResponseMetadata, Usage
31
+ from .providers import Anthropic, Azure, Google, Groq, OpenAI, OpenRouter
32
+ from .router import Router
33
+ from .session import ConversationSession
34
+ from .structured import parse_structured
28
35
 
29
36
  __all__ = [
30
37
  # Providers
@@ -46,6 +53,27 @@ __all__ = [
46
53
  "AskAIRateLimitError",
47
54
  "AskAIParsingError",
48
55
  "ProviderError",
56
+ "ProviderAuthError",
57
+ "ProviderRateLimitError",
58
+ "ProviderTimeoutError",
59
+ "ProviderInvalidRequestError",
60
+ "ProviderUnavailableError",
49
61
  "MediaTypeNotSupportedError",
62
+ "MediaSecurityError",
50
63
  "StreamNotSupportedError",
64
+ "StructuredValidationError",
65
+ "parse_structured",
66
+ "TextDelta",
67
+ "ToolCall",
68
+ "UsageUpdate",
69
+ "Completed",
70
+ "Error",
71
+ "Observability",
72
+ "ResponseMetadata",
73
+ "Usage",
74
+ "Capabilities",
75
+ "Router",
76
+ "ConversationSession",
77
+ "MediaPolicy",
78
+ "download_media",
51
79
  ]
ask_ai/base.py CHANGED
@@ -1,12 +1,17 @@
1
- import os
2
- import time
3
- import random
4
1
  import asyncio
5
2
  import logging
6
- from typing import Union, List, Dict, Any, Iterator, AsyncIterator
3
+ import os
4
+ import random
5
+ import time
6
+ from collections.abc import AsyncIterator, Iterator
7
+ from typing import Any, Optional, Union
7
8
 
9
+ from .capabilities import Capabilities
8
10
  from .config import AdvancedConfig
9
- from .media import ImageObject, AudioObject
11
+ from .events import Completed, Error, TextDelta, ToolCall, UsageUpdate
12
+ from .exceptions import StructuredValidationError
13
+ from .media import AudioObject, ImageObject
14
+ from .observability import Observability, ResponseMetadata, Usage
10
15
 
11
16
  logger = logging.getLogger("ask_ai")
12
17
 
@@ -15,10 +20,18 @@ class Response:
15
20
  """
16
21
  Unified response object for all ask-ai requests.
17
22
  """
18
- def __init__(self, text: str = "", media: Union[ImageObject, AudioObject, None] = None, response_model: Any = None, **kwargs):
23
+ def __init__(
24
+ self,
25
+ text: str = "",
26
+ media: Union[ImageObject, AudioObject, None] = None,
27
+ response_model: Any = None,
28
+ metadata: Any = None,
29
+ **kwargs,
30
+ ):
19
31
  self._raw_text = text
20
32
  self.media = media
21
33
  self.response_model = response_model
34
+ self.metadata = metadata
22
35
  self._kwargs = kwargs
23
36
 
24
37
  @property
@@ -36,7 +49,20 @@ class Response:
36
49
  return text
37
50
 
38
51
  @property
39
- def json(self) -> Union[Dict[str, Any], list]:
52
+ def raw(self) -> str:
53
+ """Return the unmodified provider text."""
54
+ return self._raw_text
55
+
56
+ @property
57
+ def parsed(self) -> Any:
58
+ """Return the validated structured response, if one was requested."""
59
+ if not self.response_model:
60
+ raise ValueError("No response_model was specified for this request.")
61
+ from .structured import parse_structured
62
+ return parse_structured(self._raw_text, self.response_model)
63
+
64
+ @property
65
+ def json(self) -> Union[dict[str, Any], list]:
40
66
  """Returns parsed JSON. If 'json=True' was passed, this is highly reliable."""
41
67
  from .utils import parse_json
42
68
  return parse_json(self._raw_text)
@@ -46,10 +72,13 @@ class Response:
46
72
  """Returns parsed Pydantic model instance if response_model was provided."""
47
73
  if not self.response_model:
48
74
  raise ValueError("No response_model was specified for this request.")
49
- return self.to_model(self.response_model)
75
+ return self.parsed
50
76
 
51
77
  def to_model(self, model_class: Any) -> Any:
52
78
  """Parse the response text into a specified Pydantic model."""
79
+ from .structured import parse_structured
80
+ if isinstance(model_class, dict) or hasattr(model_class, "model_validate") or hasattr(model_class, "parse_obj"):
81
+ return parse_structured(self._raw_text, model_class)
53
82
  text_content = self.text
54
83
  if hasattr(model_class, "model_validate_json"):
55
84
  return model_class.model_validate_json(text_content)
@@ -87,6 +116,141 @@ class BaseProvider:
87
116
  self.model = model or self._get_default_model()
88
117
  self.persona = persona
89
118
  self.config = AdvancedConfig(**kwargs)
119
+ self._middleware: list[Observability] = []
120
+
121
+ def use(self, middleware: Observability) -> "BaseProvider":
122
+ """Attach optional observability middleware and return this provider."""
123
+ self._middleware.append(middleware)
124
+ return self
125
+
126
+ def session(self, max_messages: Optional[int] = None):
127
+ from .session import ConversationSession
128
+ return ConversationSession(self, max_messages=max_messages)
129
+
130
+ def capabilities(self) -> Capabilities:
131
+ """Return conservative capabilities for custom providers."""
132
+ return Capabilities()
133
+
134
+ def _attach_metadata(self, response: Response, started_at: float, retry_count: int) -> Response:
135
+ usage = getattr(response, "usage", None)
136
+ if isinstance(usage, dict):
137
+ usage = Usage(**usage)
138
+ metadata = ResponseMetadata(
139
+ provider=self.__class__.__name__,
140
+ model=self.model,
141
+ latency_ms=(time.perf_counter() - started_at) * 1000,
142
+ retry_count=retry_count,
143
+ usage=usage,
144
+ estimated_cost=None,
145
+ )
146
+ response.metadata = metadata
147
+ for middleware in getattr(self, "_middleware", []):
148
+ metadata = ResponseMetadata(
149
+ **{**metadata.__dict__, "estimated_cost": middleware.cost(self.model, usage)}
150
+ )
151
+ response.metadata = metadata
152
+ middleware.emit({"type": "completed", "metadata": metadata, "response": response})
153
+ return response
154
+
155
+ def _attach_failure_metadata(self, error: Exception, started_at: float) -> None:
156
+ metadata = ResponseMetadata(
157
+ provider=self.__class__.__name__,
158
+ model=self.model,
159
+ latency_ms=(time.perf_counter() - started_at) * 1000,
160
+ retry_count=getattr(self, "_retry_count", 0),
161
+ failure_reason=str(error),
162
+ )
163
+ for middleware in getattr(self, "_middleware", []):
164
+ middleware.emit({"type": "failed", "metadata": metadata, "error": error})
165
+
166
+ def _validation_retry_messages(
167
+ self, messages: list[dict[str, str]], response: Response, error: StructuredValidationError
168
+ ) -> list[dict[str, str]]:
169
+ return [
170
+ *messages,
171
+ {"role": "assistant", "content": response.raw},
172
+ {
173
+ "role": "user",
174
+ "content": (
175
+ "Your previous response failed schema validation: "
176
+ f"{error}. Return a corrected JSON response only."
177
+ ),
178
+ },
179
+ ]
180
+
181
+ def _execute_response(
182
+ self,
183
+ messages: list[dict[str, str]],
184
+ config: AdvancedConfig,
185
+ output_type: Optional[str],
186
+ retry: int,
187
+ response_model: Any,
188
+ validation_retries: int,
189
+ started_at: float,
190
+ ) -> Response:
191
+ if validation_retries < 0:
192
+ raise ValueError("validation_retries must be non-negative.")
193
+ request_messages = messages
194
+ retry_count = 0
195
+ try:
196
+ for attempt in range(validation_retries + 1):
197
+ response = self._execute_with_retry(
198
+ self._send_request, request_messages, config, output_type, retry
199
+ )
200
+ retry_count += getattr(self, "_retry_count", 0)
201
+ if response_model is None:
202
+ return self._attach_metadata(response, started_at, retry_count)
203
+ response.response_model = response_model
204
+ try:
205
+ response.parsed
206
+ except StructuredValidationError as error:
207
+ if attempt == validation_retries:
208
+ raise
209
+ request_messages = self._validation_retry_messages(messages, response, error)
210
+ else:
211
+ return self._attach_metadata(response, started_at, retry_count)
212
+ raise RuntimeError("Structured validation retry loop exited unexpectedly.")
213
+ except Exception as error:
214
+ self._retry_count = retry_count + getattr(self, "_retry_count", 0)
215
+ self._attach_failure_metadata(error, started_at)
216
+ raise
217
+
218
+ async def _execute_response_async(
219
+ self,
220
+ messages: list[dict[str, str]],
221
+ config: AdvancedConfig,
222
+ output_type: Optional[str],
223
+ retry: int,
224
+ response_model: Any,
225
+ validation_retries: int,
226
+ started_at: float,
227
+ ) -> Response:
228
+ if validation_retries < 0:
229
+ raise ValueError("validation_retries must be non-negative.")
230
+ request_messages = messages
231
+ retry_count = 0
232
+ try:
233
+ for attempt in range(validation_retries + 1):
234
+ response = await self._execute_with_retry_async(
235
+ self._send_request_async, request_messages, config, output_type, retry
236
+ )
237
+ retry_count += getattr(self, "_retry_count", 0)
238
+ if response_model is None:
239
+ return self._attach_metadata(response, started_at, retry_count)
240
+ response.response_model = response_model
241
+ try:
242
+ response.parsed
243
+ except StructuredValidationError as error:
244
+ if attempt == validation_retries:
245
+ raise
246
+ request_messages = self._validation_retry_messages(messages, response, error)
247
+ else:
248
+ return self._attach_metadata(response, started_at, retry_count)
249
+ raise RuntimeError("Structured validation retry loop exited unexpectedly.")
250
+ except Exception as error:
251
+ self._retry_count = retry_count + getattr(self, "_retry_count", 0)
252
+ self._attach_failure_metadata(error, started_at)
253
+ raise
90
254
 
91
255
  def _get_api_key_env_var(self) -> str:
92
256
  """Subclasses should return the env var name, e.g. 'OPENAI_API_KEY'"""
@@ -103,7 +267,9 @@ class BaseProvider:
103
267
  """
104
268
  new_conf = AdvancedConfig(**kwargs)
105
269
  for k, v in new_conf.__dict__.items():
106
- if k == 'extra':
270
+ if k in ('extra', '_safe_mode_provided'):
271
+ continue
272
+ if k == 'safe_mode' and not new_conf._safe_mode_provided:
107
273
  continue
108
274
  if v is not None:
109
275
  setattr(self.config, k, v)
@@ -144,13 +310,13 @@ class BaseProvider:
144
310
  output_type = kwargs.get('output_type')
145
311
  retry = int(kwargs.get('retry', final_config.extra.get('retry', 0)) or 0)
146
312
  response_model = kwargs.get('response_model')
313
+ validation_retries = int(kwargs.get("validation_retries", 0) or 0)
147
314
 
148
- response = self._execute_with_retry(
149
- self._send_request, messages, final_config, output_type, retry
315
+ started_at = time.perf_counter()
316
+ return self._execute_response(
317
+ messages, final_config, output_type, retry,
318
+ response_model, validation_retries, started_at,
150
319
  )
151
- if response_model:
152
- response.response_model = response_model
153
- return response
154
320
 
155
321
  def ask_stream(self, query: str, **kwargs) -> Iterator[str]:
156
322
  """
@@ -171,9 +337,14 @@ class BaseProvider:
171
337
  else:
172
338
  p_inst = p
173
339
  try:
174
- yield from p_inst.ask_stream(query, **kwargs)
340
+ emitted = False
341
+ for chunk in p_inst.ask_stream(query, **kwargs):
342
+ emitted = True
343
+ yield chunk
175
344
  return
176
345
  except Exception as e:
346
+ if emitted:
347
+ raise
177
348
  errors.append(f"{p_inst.__class__.__name__}: {e}")
178
349
  logger.warning("Provider fallback failed for %s: %s", p_inst.__class__.__name__, e)
179
350
  from .exceptions import ProviderError
@@ -183,6 +354,39 @@ class BaseProvider:
183
354
  messages = self._prepare_messages(query, final_config)
184
355
  yield from self._send_request_stream(messages, final_config)
185
356
 
357
+ def ask_stream_events(self, query: str, **kwargs) -> Iterator[Any]:
358
+ """Yield typed events while preserving the legacy text stream API."""
359
+ fallback_providers = kwargs.pop("providers", None) or kwargs.pop("fallback", None)
360
+ if fallback_providers:
361
+ if not isinstance(fallback_providers, (list, tuple)):
362
+ fallback_providers = [fallback_providers]
363
+ for provider in fallback_providers:
364
+ provider_instance = provider() if isinstance(provider, type) else provider
365
+ yield from provider_instance.ask_stream_events(query, **kwargs)
366
+ return
367
+
368
+ emitted_chunks = 0
369
+ text_parts = []
370
+ usage = None
371
+ final_config = self._build_final_config(kwargs)
372
+ messages = self._prepare_messages(query, final_config)
373
+ try:
374
+ for event in self._send_request_stream_events(messages, final_config):
375
+ if isinstance(event, TextDelta):
376
+ emitted_chunks += 1
377
+ text_parts.append(event.text)
378
+ yield event
379
+ elif isinstance(event, UsageUpdate):
380
+ usage = event
381
+ yield event
382
+ elif isinstance(event, ToolCall):
383
+ yield event
384
+ else:
385
+ yield event
386
+ yield Completed(response=Response(text="".join(text_parts)), usage=usage)
387
+ except Exception as error:
388
+ yield Error(error=error, emitted_chunks=emitted_chunks)
389
+
186
390
  # ─── Async API ──────────────────────────────────────────
187
391
 
188
392
  async def ask_async(self, query: str, **kwargs) -> Response:
@@ -217,13 +421,13 @@ class BaseProvider:
217
421
  output_type = kwargs.get('output_type')
218
422
  retry = int(kwargs.get('retry', final_config.extra.get('retry', 0)) or 0)
219
423
  response_model = kwargs.get('response_model')
424
+ validation_retries = int(kwargs.get("validation_retries", 0) or 0)
220
425
 
221
- response = await self._execute_with_retry_async(
222
- self._send_request_async, messages, final_config, output_type, retry
426
+ started_at = time.perf_counter()
427
+ return await self._execute_response_async(
428
+ messages, final_config, output_type, retry,
429
+ response_model, validation_retries, started_at,
223
430
  )
224
- if response_model:
225
- response.response_model = response_model
226
- return response
227
431
 
228
432
  async def ask_stream_async(self, query: str, **kwargs) -> AsyncIterator[str]:
229
433
  """
@@ -244,10 +448,14 @@ class BaseProvider:
244
448
  else:
245
449
  p_inst = p
246
450
  try:
247
- async for chunk in await p_inst.ask_stream_async(query, **kwargs):
451
+ emitted = False
452
+ async for chunk in p_inst.ask_stream_async(query, **kwargs):
453
+ emitted = True
248
454
  yield chunk
249
455
  return
250
456
  except Exception as e:
457
+ if emitted:
458
+ raise
251
459
  errors.append(f"{p_inst.__class__.__name__}: {e}")
252
460
  logger.warning("Provider fallback failed for %s: %s", p_inst.__class__.__name__, e)
253
461
  from .exceptions import ProviderError
@@ -258,9 +466,43 @@ class BaseProvider:
258
466
  async for chunk in self._send_request_stream_async(messages, final_config):
259
467
  yield chunk
260
468
 
469
+ async def ask_stream_events_async(self, query: str, **kwargs) -> AsyncIterator[Any]:
470
+ """Yield typed events while preserving the legacy async text stream API."""
471
+ fallback_providers = kwargs.pop("providers", None) or kwargs.pop("fallback", None)
472
+ if fallback_providers:
473
+ if not isinstance(fallback_providers, (list, tuple)):
474
+ fallback_providers = [fallback_providers]
475
+ for provider in fallback_providers:
476
+ provider_instance = provider() if isinstance(provider, type) else provider
477
+ async for event in provider_instance.ask_stream_events_async(query, **kwargs):
478
+ yield event
479
+ return
480
+
481
+ emitted_chunks = 0
482
+ text_parts = []
483
+ usage = None
484
+ final_config = self._build_final_config(kwargs)
485
+ messages = self._prepare_messages(query, final_config)
486
+ try:
487
+ async for event in self._send_request_stream_events_async(messages, final_config):
488
+ if isinstance(event, TextDelta):
489
+ emitted_chunks += 1
490
+ text_parts.append(event.text)
491
+ yield event
492
+ elif isinstance(event, UsageUpdate):
493
+ usage = event
494
+ yield event
495
+ elif isinstance(event, ToolCall):
496
+ yield event
497
+ else:
498
+ yield event
499
+ yield Completed(response=Response(text="".join(text_parts)), usage=usage)
500
+ except Exception as error:
501
+ yield Error(error=error, emitted_chunks=emitted_chunks)
502
+
261
503
  # ─── Config Helpers ─────────────────────────────────────
262
504
 
263
- def _build_final_config(self, kwargs: Dict[str, Any]) -> AdvancedConfig:
505
+ def _build_final_config(self, kwargs: dict[str, Any]) -> AdvancedConfig:
264
506
  """Build final config by merging global config with per-request kwargs."""
265
507
  request_config = AdvancedConfig(**kwargs)
266
508
  final_config = self.config.merge(request_config)
@@ -271,7 +513,7 @@ class BaseProvider:
271
513
 
272
514
  return final_config
273
515
 
274
- def _prepare_messages(self, query: str, config: AdvancedConfig) -> List[Dict[str, str]]:
516
+ def _prepare_messages(self, query: str, config: AdvancedConfig) -> list[dict[str, str]]:
275
517
  """Build the messages list from query, persona, and config."""
276
518
  messages = []
277
519
 
@@ -317,15 +559,18 @@ class BaseProvider:
317
559
  """Execute a sync function with exponential backoff + jitter."""
318
560
  attempts = 0
319
561
  last_error = None
562
+ self._retry_count = 0
320
563
 
321
564
  while attempts <= retry:
322
565
  try:
323
566
  response = func(messages, config, output_type)
567
+ self._retry_count = attempts
324
568
  return response
325
569
  except Exception as e:
326
570
  last_error = e
327
571
  if self._is_retryable_error(e) and attempts < retry:
328
572
  attempts += 1
573
+ self._retry_count = attempts
329
574
  delay = min(2 ** attempts + random.uniform(0, 1), 30)
330
575
  logger.warning(
331
576
  "Transient error (%s). Retrying in %.1fs... (%d/%d)",
@@ -342,15 +587,18 @@ class BaseProvider:
342
587
  """Execute an async function with exponential backoff + jitter."""
343
588
  attempts = 0
344
589
  last_error = None
590
+ self._retry_count = 0
345
591
 
346
592
  while attempts <= retry:
347
593
  try:
348
594
  response = await func(messages, config, output_type)
595
+ self._retry_count = attempts
349
596
  return response
350
597
  except Exception as e:
351
598
  last_error = e
352
599
  if self._is_retryable_error(e) and attempts < retry:
353
600
  attempts += 1
601
+ self._retry_count = attempts
354
602
  delay = min(2 ** attempts + random.uniform(0, 1), 30)
355
603
  logger.warning(
356
604
  "Transient error (%s). Retrying in %.1fs... (%d/%d)",
@@ -365,43 +613,57 @@ class BaseProvider:
365
613
 
366
614
  def _is_retryable_error(self, e: Exception) -> bool:
367
615
  """Dynamically detect rate limits and network timeouts across different provider SDKs."""
368
- from .exceptions import AskAINetworkError, AskAIRateLimitError
369
- if isinstance(e, (AskAINetworkError, AskAIRateLimitError)):
616
+ from .exceptions import (
617
+ AskAINetworkError,
618
+ AskAIRateLimitError,
619
+ ProviderRateLimitError,
620
+ ProviderTimeoutError,
621
+ ProviderUnavailableError,
622
+ )
623
+ if isinstance(e, (AskAINetworkError, AskAIRateLimitError, ProviderRateLimitError,
624
+ ProviderTimeoutError, ProviderUnavailableError)):
370
625
  return True
371
-
372
- err_str = str(e).lower()
373
- err_cls = e.__class__.__name__.lower()
374
-
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)
626
+ return False
377
627
 
378
628
  # ─── Abstract Methods (Subclasses Must Implement) ──────
379
629
 
380
- def _send_request(self, messages: List[Dict[str, str]], config: AdvancedConfig, output_type: str = None) -> Response:
630
+ def _send_request(self, messages: list[dict[str, str]], config: AdvancedConfig, output_type: str = None) -> Response:
381
631
  """Sync request — subclasses must implement."""
382
632
  raise NotImplementedError
383
633
 
384
- async def _send_request_async(self, messages: List[Dict[str, str]], config: AdvancedConfig, output_type: str = None) -> Response:
634
+ async def _send_request_async(self, messages: list[dict[str, str]], config: AdvancedConfig, output_type: str = None) -> Response:
385
635
  """Async request — subclasses should override for native async."""
386
636
  # Default fallback: run sync in thread pool
387
637
  loop = asyncio.get_running_loop()
388
638
  return await loop.run_in_executor(None, self._send_request, messages, config, output_type)
389
639
 
390
- def _send_request_stream(self, messages: List[Dict[str, str]], config: AdvancedConfig) -> Iterator[str]:
640
+ def _send_request_stream(self, messages: list[dict[str, str]], config: AdvancedConfig) -> Iterator[str]:
391
641
  """Sync streaming — subclasses should override."""
392
642
  from .exceptions import StreamNotSupportedError
393
643
  raise StreamNotSupportedError(f"{self.__class__.__name__} does not support streaming.")
394
644
 
395
- async def _send_request_stream_async(self, messages: List[Dict[str, str]], config: AdvancedConfig) -> AsyncIterator[str]:
645
+ def _send_request_stream_events(self, messages: list[dict[str, str]], config: AdvancedConfig) -> Iterator[Any]:
646
+ for index, chunk in enumerate(self._send_request_stream(messages, config)):
647
+ yield TextDelta(text=chunk, index=index)
648
+
649
+ async def _send_request_stream_async(self, messages: list[dict[str, str]], config: AdvancedConfig) -> AsyncIterator[str]:
396
650
  """Async streaming — subclasses should override for native async."""
397
651
  # Default fallback: wrap sync stream
398
652
  loop = asyncio.get_running_loop()
399
653
  for chunk in await loop.run_in_executor(None, lambda: list(self._send_request_stream(messages, config))):
400
654
  yield chunk
401
655
 
656
+ async def _send_request_stream_events_async(
657
+ self, messages: list[dict[str, str]], config: AdvancedConfig
658
+ ) -> AsyncIterator[Any]:
659
+ index = 0
660
+ async for chunk in self._send_request_stream_async(messages, config):
661
+ yield TextDelta(text=chunk, index=index)
662
+ index += 1
663
+
402
664
  # ─── Tool Definitions for Smart Intent ─────────────────
403
665
 
404
- def _get_media_tools(self) -> List[Dict[str, Any]]:
666
+ def _get_media_tools(self) -> list[dict[str, Any]]:
405
667
  return [
406
668
  {
407
669
  "type": "function",
ask_ai/capabilities.py ADDED
@@ -0,0 +1,17 @@
1
+ from dataclasses import asdict, dataclass
2
+ from typing import Any
3
+
4
+
5
+ @dataclass(frozen=True)
6
+ class Capabilities:
7
+ text: bool = True
8
+ streaming: bool = False
9
+ async_streaming: bool = False
10
+ structured_output: bool = False
11
+ tool_calls: bool = False
12
+ image_generation: bool = False
13
+ audio_generation: bool = False
14
+ vision: bool = False
15
+
16
+ def to_dict(self) -> dict[str, Any]:
17
+ return asdict(self)
ask_ai/cli.py CHANGED
@@ -1,16 +1,7 @@
1
- import sys
2
1
  import argparse
3
- from . import __version__
4
- from . import (
5
- OpenAI,
6
- Groq,
7
- Google,
8
- OpenRouter,
9
- Azure,
10
- Anthropic,
11
- AskAIError
12
- )
2
+ import sys
13
3
 
4
+ from . import Anthropic, AskAIError, Azure, Google, Groq, OpenAI, OpenRouter, __version__
14
5
 
15
6
  PROVIDER_MAP = {
16
7
  "openai": OpenAI,
@@ -70,14 +61,9 @@ def main():
70
61
 
71
62
  # Stream or regular
72
63
  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)
64
+ for chunk in ai.ask_stream(args.query, **ask_kwargs):
65
+ print(chunk, end="", flush=True)
66
+ print() # newline at end
81
67
  else:
82
68
  response = ai.ask(args.query, **ask_kwargs)
83
69