askai-python 0.4.0__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
@@ -2,7 +2,7 @@
2
2
  ask-ai: A minimal Python SDK to switch between LLM providers in one line.
3
3
  """
4
4
 
5
- __version__ = "0.4.0"
5
+ __version__ = "0.4.1"
6
6
 
7
7
  from .providers import (
8
8
  OpenAI,
ask_ai/base.py CHANGED
@@ -15,9 +15,10 @@ class Response:
15
15
  """
16
16
  Unified response object for all ask-ai requests.
17
17
  """
18
- 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):
19
19
  self._raw_text = text
20
20
  self.media = media
21
+ self.response_model = response_model
21
22
  self._kwargs = kwargs
22
23
 
23
24
  @property
@@ -40,6 +41,25 @@ class Response:
40
41
  from .utils import parse_json
41
42
  return parse_json(self._raw_text)
42
43
 
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
+
43
63
  def __str__(self) -> str:
44
64
  return self.text
45
65
 
@@ -97,19 +117,68 @@ class BaseProvider:
97
117
  The main entry point. Sync version.
98
118
  Detects intent, manages config, and returns a unified Response.
99
119
  """
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
+
100
142
  final_config = self._build_final_config(kwargs)
101
143
  messages = self._prepare_messages(query, final_config)
102
144
  output_type = kwargs.get('output_type')
103
145
  retry = int(kwargs.get('retry', final_config.extra.get('retry', 0)) or 0)
146
+ response_model = kwargs.get('response_model')
104
147
 
105
- return self._execute_with_retry(
148
+ response = self._execute_with_retry(
106
149
  self._send_request, messages, final_config, output_type, retry
107
150
  )
151
+ if response_model:
152
+ response.response_model = response_model
153
+ return response
108
154
 
109
155
  def ask_stream(self, query: str, **kwargs) -> Iterator[str]:
110
156
  """
111
157
  Streaming text generation. Yields text chunks.
112
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}")
181
+
113
182
  final_config = self._build_final_config(kwargs)
114
183
  messages = self._prepare_messages(query, final_config)
115
184
  yield from self._send_request_stream(messages, final_config)
@@ -121,19 +190,69 @@ class BaseProvider:
121
190
  Native async version of ask().
122
191
  Uses provider's async client directly — no thread pool wrapping.
123
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
+
124
215
  final_config = self._build_final_config(kwargs)
125
216
  messages = self._prepare_messages(query, final_config)
126
217
  output_type = kwargs.get('output_type')
127
218
  retry = int(kwargs.get('retry', final_config.extra.get('retry', 0)) or 0)
219
+ response_model = kwargs.get('response_model')
128
220
 
129
- return await self._execute_with_retry_async(
221
+ response = await self._execute_with_retry_async(
130
222
  self._send_request_async, messages, final_config, output_type, retry
131
223
  )
224
+ if response_model:
225
+ response.response_model = response_model
226
+ return response
132
227
 
133
228
  async def ask_stream_async(self, query: str, **kwargs) -> AsyncIterator[str]:
134
229
  """
135
230
  Async streaming text generation. Yields text chunks.
136
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
+
137
256
  final_config = self._build_final_config(kwargs)
138
257
  messages = self._prepare_messages(query, final_config)
139
258
  async for chunk in self._send_request_stream_async(messages, final_config):
@@ -158,8 +277,31 @@ class BaseProvider:
158
277
 
159
278
  system_msg = config.system_message or self.persona or ""
160
279
 
161
- # Smart capability: if json=True, enforce JSON system prompt
162
- if config.extra.get('json'):
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'):
163
305
  json_instruction = "You must respond with valid JSON only, without any markdown formatting or tags."
164
306
  system_msg = f"{system_msg}\n{json_instruction}".strip()
165
307
 
@@ -54,13 +54,70 @@ class OpenRouterProvider(OpenAIProvider):
54
54
  return []
55
55
 
56
56
  def _generate_image(self, messages, config):
57
- raise ProviderError("OpenRouter does not support Image Generation via this library.")
57
+ prompt = messages[-1]["content"]
58
+ try:
59
+ import httpx
60
+ except ImportError:
61
+ raise ProviderError("httpx client library is required for OpenRouter Image Generation. Run: pip install httpx")
62
+
63
+ from ..media import ImageObject
64
+ from .openai_provider import _download_bytes
65
+
66
+ headers = {
67
+ "Authorization": f"Bearer {self.api_key}",
68
+ "Content-Type": "application/json"
69
+ }
70
+ payload = {
71
+ "model": self.model,
72
+ "prompt": prompt
73
+ }
74
+ try:
75
+ response = httpx.post("https://openrouter.ai/api/v1/images", json=payload, headers=headers, timeout=60.0)
76
+ response.raise_for_status()
77
+ data = response.json()
78
+ image_url = data["data"][0]["url"]
79
+ img_data = _download_bytes(image_url)
80
+ return Response(
81
+ text=f"Generated image via OpenRouter ({self.model}) for: {prompt}",
82
+ media=ImageObject(img_data)
83
+ )
84
+ except Exception as e:
85
+ raise ProviderError(f"OpenRouter Image Generation Error: {e}") from e
86
+
87
+ async def _generate_image_async(self, messages, config):
88
+ prompt = messages[-1]["content"]
89
+ try:
90
+ import httpx
91
+ except ImportError:
92
+ raise ProviderError("httpx client library is required for OpenRouter Image Generation. Run: pip install httpx")
93
+
94
+ from ..media import ImageObject
95
+ from .openai_provider import _download_bytes_async
96
+
97
+ headers = {
98
+ "Authorization": f"Bearer {self.api_key}",
99
+ "Content-Type": "application/json"
100
+ }
101
+ payload = {
102
+ "model": self.model,
103
+ "prompt": prompt
104
+ }
105
+ try:
106
+ async with httpx.AsyncClient() as client:
107
+ response = await client.post("https://openrouter.ai/api/v1/images", json=payload, headers=headers, timeout=60.0)
108
+ response.raise_for_status()
109
+ data = response.json()
110
+ image_url = data["data"][0]["url"]
111
+ img_data = await _download_bytes_async(image_url)
112
+ return Response(
113
+ text=f"Generated image via OpenRouter ({self.model}) for: {prompt}",
114
+ media=ImageObject(img_data)
115
+ )
116
+ except Exception as e:
117
+ raise ProviderError(f"OpenRouter Image Generation Error: {e}") from e
58
118
 
59
119
  def _generate_audio(self, messages, config):
60
120
  raise ProviderError("OpenRouter does not support Audio Generation via this library.")
61
121
 
62
- async def _generate_image_async(self, messages, config):
63
- raise ProviderError("OpenRouter does not support Image Generation via this library.")
64
-
65
122
  async def _generate_audio_async(self, messages, config):
66
123
  raise ProviderError("OpenRouter does not support Audio Generation via this library.")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: askai-python
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
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
@@ -143,6 +143,54 @@ response = ai.ask(
143
143
  )
144
144
  ```
145
145
 
146
+ ## 🎨 Media Generation (Image & Audio)
147
+
148
+ Generate images or speech with compatible providers:
149
+
150
+ ```python
151
+ from ask_ai import OpenAI
152
+
153
+ ai = OpenAI()
154
+
155
+ # Generate an Image (DALL-E)
156
+ img_response = ai.ask("A majestic lion in a neon city", output_type="image")
157
+ img_response.save("lion.png")
158
+
159
+ # Generate Speech (TTS)
160
+ audio_response = ai.ask("Hello, this is a voice.", output_type="audio")
161
+ audio_response.save("welcome.mp3")
162
+ ```
163
+
164
+ ## 🔗 Provider Fallback
165
+
166
+ Never experience downtime by supplying fallback providers:
167
+
168
+ ```python
169
+ from ask_ai import OpenAI, Groq
170
+
171
+ ai = OpenAI()
172
+ response = ai.ask("Explain quantum physics", providers=[ai, Groq])
173
+ print(response.text)
174
+ ```
175
+
176
+ ## 📋 Structured Output (Pydantic Support)
177
+
178
+ Force LLMs to strictly respond matching a Pydantic schema:
179
+
180
+ ```python
181
+ from pydantic import BaseModel
182
+ from ask_ai import OpenAI
183
+
184
+ class User(BaseModel):
185
+ name: str
186
+ age: int
187
+
188
+ ai = OpenAI()
189
+ response = ai.ask("Extract name: Alice is 30.", response_model=User)
190
+ user = response.pydantic
191
+ print(user.name) # "Alice"
192
+ ```
193
+
146
194
  ## 🔌 Supported Providers
147
195
 
148
196
  | Provider | Class | Capabilities |
@@ -158,11 +206,19 @@ response = ai.ask(
158
206
 
159
207
  ## 🗺️ Roadmap
160
208
 
161
- - [x] Baseline multiple providers
162
- - [x] Automated Retry & Timeout controls
163
- - [ ] Async API (`await ask_async`)
164
- - [ ] Provider Fallback (`fallback=[Groq()]`)
165
- - [ ] Structured Outputs (Pydantic Support)
209
+ ### 🚀 Roadmap 2.0 (Active)
210
+ - [x] Provider Fallback chaining & Pydantic Structured Output support
211
+ - [ ] Multi-Modal Vision input (image to text) for all key providers
212
+ - [ ] Native support for tools/function calling execution
213
+ - [ ] Memory buffer / session-based conversation management
214
+
215
+ ### 🏁 Roadmap 1.0 (Completed)
216
+ - ~~[x] Baseline multiple providers~~
217
+ - ~~[x] Automated Retry & Timeout controls~~
218
+ - ~~[x] Async API (`await ask_async`)~~
219
+ - ~~[x] Streaming Support (`ask_stream` / `ask_stream_async`)~~
220
+ - ~~[x] Provider Fallback (`fallback=[Groq()]`)~~
221
+ - ~~[x] Structured Outputs (Pydantic Support)~~
166
222
 
167
223
  ---
168
224
 
@@ -1,6 +1,6 @@
1
- ask_ai/__init__.py,sha256=Q-wjxDV6wB0f4T3XW4LVwLXqL71BF1EyMq_LMLIKxIg,959
1
+ ask_ai/__init__.py,sha256=HS8rfHKtsMeaWrb-8D_KcJHH1zlPq8oKkuaN9SITHJY,959
2
2
  ask_ai/__main__.py,sha256=MSmt_5Xg84uHqzTN38JwgseJK8rsJn_11A8WD99VtEo,61
3
- ask_ai/base.py,sha256=m3ECA-fvNjxBmOfGwQsTtM7d06qO-f6iDBPLZzA5kHg,12554
3
+ ask_ai/base.py,sha256=EvYHJF_dxp0HNnL3copjf0FoP9I747q4--0rUcriT9k,18742
4
4
  ask_ai/cli.py,sha256=t8iN3YGP4dgNVZ1THKfdm-U4eJF1zdB8UwNP7R9XUUw,3229
5
5
  ask_ai/config.py,sha256=xM5pySnWWWjyg_WHGtGWY8XZ_aTDdIIOf65Xs1Vc2UY,2664
6
6
  ask_ai/exceptions.py,sha256=i3qW9R7jeYEJiLhuEA6Pr65bEPTbZtMqASSt8ol_V2w,1025
@@ -13,10 +13,10 @@ ask_ai/providers/azure_provider.py,sha256=F97Na3aaEQ9EJx5rI6ekk1RTPTAl1NwVWAWoyU
13
13
  ask_ai/providers/google_provider.py,sha256=svXMqIHufI6ztkK51Ahgzz0aYUBirPk4ym7QETnbL88,5911
14
14
  ask_ai/providers/groq_provider.py,sha256=zF4LmGEsC8PKJ_jc_aoN8JHHALmpMuyeKmr5HNKB9NI,4877
15
15
  ask_ai/providers/openai_provider.py,sha256=_kRPvmV6jy1i69nqD22AlCu8vcw3lj7VjFoQOFAmwmU,10476
16
- ask_ai/providers/openrouter_provider.py,sha256=GEsu50GBZsF00BkOPdN44byRqZc5TI_f-WoIdkZjNEA,2319
17
- askai_python-0.4.0.dist-info/licenses/LICENSE,sha256=SUSbAUsh_Uqw7NDEKwAGRVPS7sojihBHIBGBx6WZxvg,1073
18
- askai_python-0.4.0.dist-info/METADATA,sha256=z6-8kdHqHieI3_xYhnzvVJz1LvhVyYeL23ip6S_nlBs,6052
19
- askai_python-0.4.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
20
- askai_python-0.4.0.dist-info/entry_points.txt,sha256=Vh_PDH-aoeiZEFtZMM18kVLqVBE70EP9yvvXeZ9Lo4U,43
21
- askai_python-0.4.0.dist-info/top_level.txt,sha256=fW2GapXaz6f-2UAuzVVPUPNfZUK_ZS1dpLyCllcE03s,7
22
- askai_python-0.4.0.dist-info/RECORD,,
16
+ ask_ai/providers/openrouter_provider.py,sha256=SzjULohgRhL8Uff_S_0OlFLffdiyde_0wNC-NNuNQHU,4506
17
+ askai_python-0.4.1.dist-info/licenses/LICENSE,sha256=SUSbAUsh_Uqw7NDEKwAGRVPS7sojihBHIBGBx6WZxvg,1073
18
+ askai_python-0.4.1.dist-info/METADATA,sha256=NtqvYM2uJCWGxSjzFc8MoDRrq6jWrViszzJ-VVu3qxs,7481
19
+ askai_python-0.4.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
20
+ askai_python-0.4.1.dist-info/entry_points.txt,sha256=Vh_PDH-aoeiZEFtZMM18kVLqVBE70EP9yvvXeZ9Lo4U,43
21
+ askai_python-0.4.1.dist-info/top_level.txt,sha256=fW2GapXaz6f-2UAuzVVPUPNfZUK_ZS1dpLyCllcE03s,7
22
+ askai_python-0.4.1.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (82.0.1)
2
+ Generator: setuptools (83.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5