swiftpy-framework 0.1.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.
Files changed (211) hide show
  1. swiftpy/.gitignore +0 -0
  2. swiftpy/__init__.py +572 -0
  3. swiftpy/admin/__init__.py +50 -0
  4. swiftpy/admin/auth.py +522 -0
  5. swiftpy/admin/registry.py +567 -0
  6. swiftpy/admin/views.py +1583 -0
  7. swiftpy/ai/__init__.py +199 -0
  8. swiftpy/ai/_deterministic.py +40 -0
  9. swiftpy/ai/_fake.py +604 -0
  10. swiftpy/ai/agents/__init__.py +30 -0
  11. swiftpy/ai/agents/base_agent.py +506 -0
  12. swiftpy/ai/agents/memory.py +864 -0
  13. swiftpy/ai/client.py +1011 -0
  14. swiftpy/ai/conversation.py +171 -0
  15. swiftpy/ai/embeddings.py +321 -0
  16. swiftpy/ai/exceptions.py +75 -0
  17. swiftpy/ai/history_strategies.py +157 -0
  18. swiftpy/ai/middleware.py +918 -0
  19. swiftpy/ai/observability/__init__.py +28 -0
  20. swiftpy/ai/observability/drivers.py +763 -0
  21. swiftpy/ai/observability/observer.py +364 -0
  22. swiftpy/ai/pool.py +102 -0
  23. swiftpy/ai/pricing.py +101 -0
  24. swiftpy/ai/prompts/__init__.py +6 -0
  25. swiftpy/ai/prompts/loader.py +343 -0
  26. swiftpy/ai/providers/__init__.py +22 -0
  27. swiftpy/ai/providers/anthropic_provider.py +353 -0
  28. swiftpy/ai/providers/base.py +273 -0
  29. swiftpy/ai/providers/openai_provider.py +275 -0
  30. swiftpy/ai/rag/__init__.py +53 -0
  31. swiftpy/ai/rag/context_formatter.py +263 -0
  32. swiftpy/ai/rag/pipeline_builder.py +544 -0
  33. swiftpy/ai/rag/reranker.py +484 -0
  34. swiftpy/ai/rag/result.py +78 -0
  35. swiftpy/ai/rag/retriever.py +315 -0
  36. swiftpy/ai/redis_store.py +227 -0
  37. swiftpy/ai/streaming.py +115 -0
  38. swiftpy/ai/tools/__init__.py +22 -0
  39. swiftpy/ai/tools/base_tool.py +489 -0
  40. swiftpy/ai/tools/first_party.py +384 -0
  41. swiftpy/ai/tools/tool_loop.py +417 -0
  42. swiftpy/ai/vectorstore/__init__.py +46 -0
  43. swiftpy/ai/vectorstore/base.py +542 -0
  44. swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
  45. swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
  46. swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
  47. swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
  48. swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
  49. swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
  50. swiftpy/auth/__init__.py +50 -0
  51. swiftpy/auth/api_key_guard.py +544 -0
  52. swiftpy/auth/auth_manager.py +259 -0
  53. swiftpy/auth/exceptions.py +71 -0
  54. swiftpy/auth/jwt_guard.py +230 -0
  55. swiftpy/auth/jwt_issuer.py +658 -0
  56. swiftpy/auth/token_blacklist.py +300 -0
  57. swiftpy/cache/__init__.py +14 -0
  58. swiftpy/cache/cache.py +251 -0
  59. swiftpy/cache/driver.py +216 -0
  60. swiftpy/cache/drivers/memory.py +199 -0
  61. swiftpy/cache/drivers/redis.py +421 -0
  62. swiftpy/console/__init__.py +0 -0
  63. swiftpy/console/cli.py +68 -0
  64. swiftpy/console/commands/__init__.py +0 -0
  65. swiftpy/console/commands/cache.py +164 -0
  66. swiftpy/console/commands/make.py +935 -0
  67. swiftpy/console/commands/migrate.py +280 -0
  68. swiftpy/console/commands/new.py +1806 -0
  69. swiftpy/console/commands/queue.py +306 -0
  70. swiftpy/console/commands/seed.py +252 -0
  71. swiftpy/console/commands/start.py +171 -0
  72. swiftpy/console/commands/storage.py +104 -0
  73. swiftpy/console/loader.py +21 -0
  74. swiftpy/console/stubs/__init__.py +26 -0
  75. swiftpy/console/stubs/controller.stub +14 -0
  76. swiftpy/console/stubs/event.stub +14 -0
  77. swiftpy/console/stubs/job.stub +22 -0
  78. swiftpy/console/stubs/listener.stub +14 -0
  79. swiftpy/console/stubs/mail.stub +20 -0
  80. swiftpy/console/stubs/middleware.stub +16 -0
  81. swiftpy/console/stubs/migration.alter.stub +17 -0
  82. swiftpy/console/stubs/migration.api_keys.stub +22 -0
  83. swiftpy/console/stubs/migration.blank.stub +11 -0
  84. swiftpy/console/stubs/migration.create.stub +15 -0
  85. swiftpy/console/stubs/migration.stub +11 -0
  86. swiftpy/console/stubs/migration.update.stub +17 -0
  87. swiftpy/console/stubs/model.stub +17 -0
  88. swiftpy/console/stubs/provider.stub +18 -0
  89. swiftpy/console/stubs/repository.stub +10 -0
  90. swiftpy/console/stubs/request.stub +16 -0
  91. swiftpy/console/stubs/resource.stub +21 -0
  92. swiftpy/console/stubs/schema.stub +12 -0
  93. swiftpy/console/stubs/seeder.stub +14 -0
  94. swiftpy/console/stubs/service.stub +10 -0
  95. swiftpy/core/__init__.py +0 -0
  96. swiftpy/core/application.py +1306 -0
  97. swiftpy/core/bootstrap.py +30 -0
  98. swiftpy/core/config.py +432 -0
  99. swiftpy/core/container.py +596 -0
  100. swiftpy/core/container_exceptions.py +18 -0
  101. swiftpy/core/context.py +148 -0
  102. swiftpy/core/exceptions.py +352 -0
  103. swiftpy/core/providers/base.py +35 -0
  104. swiftpy/database/__init__.py +41 -0
  105. swiftpy/database/exceptions.py +42 -0
  106. swiftpy/database/identifiers.py +105 -0
  107. swiftpy/database/migrations/__init__.py +36 -0
  108. swiftpy/database/migrations/blueprint.py +431 -0
  109. swiftpy/database/migrations/lock.py +104 -0
  110. swiftpy/database/migrations/migration.py +22 -0
  111. swiftpy/database/migrations/migrator.py +507 -0
  112. swiftpy/database/migrations/schema.py +435 -0
  113. swiftpy/database/orm/__init__.py +33 -0
  114. swiftpy/database/orm/fields.py +92 -0
  115. swiftpy/database/orm/model.py +2034 -0
  116. swiftpy/database/orm/registry.py +33 -0
  117. swiftpy/database/orm/relationships.py +836 -0
  118. swiftpy/database/pagination/__init__.py +16 -0
  119. swiftpy/database/pagination/contracts.py +60 -0
  120. swiftpy/database/pagination/paginator.py +112 -0
  121. swiftpy/database/pagination/simple_paginator.py +97 -0
  122. swiftpy/database/pool.py +218 -0
  123. swiftpy/database/query_builder.py +1803 -0
  124. swiftpy/database/seeder.py +39 -0
  125. swiftpy/database/transaction.py +276 -0
  126. swiftpy/events/__init__.py +10 -0
  127. swiftpy/events/dispatcher.py +308 -0
  128. swiftpy/events/redis_bus.py +552 -0
  129. swiftpy/filesystem/__init__.py +227 -0
  130. swiftpy/filesystem/disk.py +378 -0
  131. swiftpy/filesystem/drivers/__init__.py +11 -0
  132. swiftpy/filesystem/drivers/base.py +156 -0
  133. swiftpy/filesystem/drivers/local.py +559 -0
  134. swiftpy/filesystem/drivers/s3.py +655 -0
  135. swiftpy/filesystem/events.py +37 -0
  136. swiftpy/filesystem/exceptions.py +127 -0
  137. swiftpy/filesystem/manager.py +138 -0
  138. swiftpy/filesystem/metadata.py +22 -0
  139. swiftpy/filesystem/paths.py +99 -0
  140. swiftpy/filesystem/service_provider.py +17 -0
  141. swiftpy/filesystem/uploads/__init__.py +13 -0
  142. swiftpy/filesystem/uploads/handlers.py +362 -0
  143. swiftpy/filesystem/uploads/uploaded_file.py +222 -0
  144. swiftpy/filesystem/uploads/validators.py +186 -0
  145. swiftpy/filesystem/visibility.py +22 -0
  146. swiftpy/helpers/__init__.py +42 -0
  147. swiftpy/helpers/helper.py +180 -0
  148. swiftpy/http/exceptions.py +41 -0
  149. swiftpy/http/middleware.py +307 -0
  150. swiftpy/http/request.py +375 -0
  151. swiftpy/http/response.py +442 -0
  152. swiftpy/http/router.py +1050 -0
  153. swiftpy/mail/__init__.py +58 -0
  154. swiftpy/mail/attachments.py +255 -0
  155. swiftpy/mail/config.py +71 -0
  156. swiftpy/mail/envelope.py +183 -0
  157. swiftpy/mail/exceptions.py +90 -0
  158. swiftpy/mail/facade.py +312 -0
  159. swiftpy/mail/mailable.py +53 -0
  160. swiftpy/mail/manager.py +240 -0
  161. swiftpy/mail/message.py +186 -0
  162. swiftpy/mail/models.py +19 -0
  163. swiftpy/mail/queue/__init__.py +5 -0
  164. swiftpy/mail/queue/job.py +279 -0
  165. swiftpy/mail/service_provider.py +19 -0
  166. swiftpy/mail/templates/__init__.py +5 -0
  167. swiftpy/mail/templates/renderer.py +173 -0
  168. swiftpy/mail/transports/__init__.py +21 -0
  169. swiftpy/mail/transports/api/__init__.py +13 -0
  170. swiftpy/mail/transports/api/base.py +194 -0
  171. swiftpy/mail/transports/api/mailgun.py +158 -0
  172. swiftpy/mail/transports/api/resend.py +156 -0
  173. swiftpy/mail/transports/api/sendgrid.py +172 -0
  174. swiftpy/mail/transports/base.py +24 -0
  175. swiftpy/mail/transports/console.py +91 -0
  176. swiftpy/mail/transports/sendmail.py +128 -0
  177. swiftpy/mail/transports/smtp.py +466 -0
  178. swiftpy/queue/__init__.py +13 -0
  179. swiftpy/queue/dispatcher.py +813 -0
  180. swiftpy/queue/job.py +541 -0
  181. swiftpy/queue/worker.py +556 -0
  182. swiftpy/support/__init__.py +12 -0
  183. swiftpy/support/keys.py +108 -0
  184. swiftpy/support/redis_config.py +223 -0
  185. swiftpy/support/strings.py +114 -0
  186. swiftpy/testing/__init__.py +20 -0
  187. swiftpy/testing/fakes/__init__.py +11 -0
  188. swiftpy/testing/fakes/ai_fake.py +21 -0
  189. swiftpy/testing/fakes/vectorstore_fake.py +194 -0
  190. swiftpy/testing/fixtures.py +124 -0
  191. swiftpy/testing/http_client.py +730 -0
  192. swiftpy/testing/test_case.py +126 -0
  193. swiftpy/validation/__init__.py +97 -0
  194. swiftpy/validation/exceptions.py +26 -0
  195. swiftpy/validation/form_request.py +302 -0
  196. swiftpy/validation/protocols.py +33 -0
  197. swiftpy/validation/rules/__init__.py +89 -0
  198. swiftpy/validation/rules/builtin.py +391 -0
  199. swiftpy/validation/rules/exists.py +86 -0
  200. swiftpy/validation/rules/registry.py +290 -0
  201. swiftpy/validation/rules/required.py +55 -0
  202. swiftpy/validation/rules/unique.py +138 -0
  203. swiftpy/validation/validator.py +612 -0
  204. swiftpy/websockets/__init__.py +19 -0
  205. swiftpy/websockets/connection.py +317 -0
  206. swiftpy/websockets/manager.py +271 -0
  207. swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
  208. swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
  209. swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
  210. swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
  211. swiftpy_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
swiftpy/ai/client.py ADDED
@@ -0,0 +1,1011 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import copy
6
+ import time
7
+ from collections.abc import AsyncGenerator, AsyncIterator, Coroutine, Sequence
8
+ from typing import TYPE_CHECKING, Any, ClassVar, Self
9
+
10
+ from swiftpy.ai._fake import AIFake, FixtureType
11
+ from swiftpy.ai.embeddings import EmbeddingsClient
12
+ from swiftpy.ai.exceptions import (
13
+ AIConfigurationError,
14
+ AIModuleDisabledError,
15
+ )
16
+ from swiftpy.ai.middleware import AIMiddlewarePipeline
17
+ from swiftpy.ai.observability.observer import (
18
+ ObservationContext,
19
+ compute_prompt_hash,
20
+ get_observability,
21
+ )
22
+ from swiftpy.ai.pool import AIHttpPool
23
+ from swiftpy.ai.prompts.loader import aload_prompt, load_prompt
24
+ from swiftpy.ai.providers.anthropic_provider import AnthropicProvider
25
+ from swiftpy.ai.providers.base import (
26
+ INTERNAL_ATTRIBUTION_KEYS,
27
+ LLMRequest,
28
+ LLMResponse,
29
+ Message,
30
+ ProviderInterface,
31
+ TokenUsage,
32
+ )
33
+ from swiftpy.ai.providers.openai_provider import OpenAIProvider
34
+ from swiftpy.ai.tools.base_tool import BaseTool
35
+ from swiftpy.ai.tools.tool_loop import ToolLoop
36
+ from swiftpy.ai.vectorstore.base import VectorStore
37
+ from swiftpy.core.config import Config
38
+
39
+ if TYPE_CHECKING:
40
+ from swiftpy.ai.conversation import Conversation
41
+
42
+ _DEFAULT_PROVIDER_MODELS: dict[str, str] = {
43
+ "openai": "gpt-4o",
44
+ "anthropic": "claude-3-5-sonnet-20241022",
45
+ }
46
+
47
+
48
+ def _consume_task_exception(task: asyncio.Task[Any]) -> None:
49
+ """Retrieve exceptions from background tasks so asyncio never warns on GC."""
50
+ if not task.cancelled():
51
+ task.exception()
52
+
53
+
54
+ def _estimate_token_count(text: str) -> int:
55
+ """Estimate token count for text, returning 0 for empty content."""
56
+ if not text:
57
+ return 0
58
+ return max(1, len(text) // 4)
59
+
60
+
61
+ def _is_real_int(value: Any) -> bool:
62
+ """Return True if value is an int and not a bool."""
63
+ return isinstance(value, int) and not isinstance(value, bool)
64
+
65
+
66
+ def _is_real_number(value: Any) -> bool:
67
+ """Return True if value is an int/float and not a bool."""
68
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
69
+
70
+
71
+ async def _next_stream_chunk(gen_iter: AsyncIterator[str]) -> str:
72
+ """Await the next chunk from an async stream iterator as a coroutine."""
73
+ return await gen_iter.__anext__()
74
+
75
+
76
+ async def _await_or_abort(
77
+ waiter_coro: Coroutine[Any, Any, Any],
78
+ abort_event: asyncio.Event,
79
+ ) -> tuple[bool, Any]:
80
+ """Await waiter_coro unless abort_event fires first; returns (aborted, result)."""
81
+ wait_task = asyncio.create_task(waiter_coro)
82
+ wait_task.add_done_callback(_consume_task_exception)
83
+ abort_task = asyncio.create_task(abort_event.wait())
84
+ abort_task.add_done_callback(_consume_task_exception)
85
+ try:
86
+ await asyncio.wait(
87
+ {wait_task, abort_task},
88
+ return_when=asyncio.FIRST_COMPLETED,
89
+ )
90
+ finally:
91
+ if not abort_task.done():
92
+ abort_task.cancel()
93
+ with contextlib.suppress(asyncio.CancelledError):
94
+ await abort_task
95
+ if abort_event.is_set():
96
+ if not wait_task.done():
97
+ wait_task.cancel()
98
+ with contextlib.suppress(asyncio.CancelledError, StopAsyncIteration, Exception):
99
+ await wait_task
100
+ return True, None
101
+ return False, await wait_task
102
+
103
+
104
+ class AIClient:
105
+ """
106
+ Fluent builder client for LLM completions.
107
+
108
+ Provides a chained builder interface:
109
+ response = await (
110
+ AIClient()
111
+ .system("You are a helpful coding assistant.")
112
+ .user("Explain Python async/await in two sentences.")
113
+ .temperature(0.7)
114
+ .max_tokens(150)
115
+ .send()
116
+ )
117
+ """
118
+
119
+ _registry: ClassVar[dict[str, type[ProviderInterface] | ProviderInterface]] = {
120
+ "openai": OpenAIProvider,
121
+ "anthropic": AnthropicProvider,
122
+ }
123
+ _global_middlewares: ClassVar[list[Any]] = []
124
+
125
+ def __init__(
126
+ self,
127
+ config: Config | None = None,
128
+ http_pool: AIHttpPool | None = None,
129
+ default_provider: str | None = None,
130
+ ) -> None:
131
+ if config is None:
132
+ try:
133
+ self._config = Config.get_instance()
134
+ except RuntimeError:
135
+ self._config = Config()
136
+ else:
137
+ self._config = config
138
+
139
+ self._http_pool = http_pool or AIHttpPool.get_instance()
140
+ self._provider_name: str | None = default_provider
141
+
142
+ self._messages: list[Message] = []
143
+ self._temperature: float | None = None
144
+ self._max_tokens: int | None = None
145
+ self._top_p: float | None = None
146
+ self._stop: list[str] | None = None
147
+ self._model: str | None = None
148
+ self._extra: dict[str, Any] = {}
149
+ self._metadata: dict[str, Any] = {}
150
+ self._attached_conversation: Conversation | None = None
151
+ self._pending_conversation_messages: list[Message] = []
152
+ self._tools: list[BaseTool] = []
153
+ self._auto_execute_tools: bool = False
154
+ self._max_iterations: int = 10
155
+ self._max_tool_calls: int | None = None
156
+ self._allowed_tools: Sequence[str] | None = None
157
+ self._tool_timeout: float | None = None
158
+ self._max_total_tokens: int | None = None
159
+ self._max_cost: float | None = None
160
+ self._suppress_tools_in_request: bool = False
161
+ self._is_executing_tool_loop: bool = False
162
+ self._last_tool_loop: ToolLoop | None = None
163
+ self._middlewares: list[Any] = []
164
+
165
+ def clone(
166
+ self,
167
+ *,
168
+ include_messages: bool = False,
169
+ include_conversation: bool = True,
170
+ ) -> AIClient:
171
+ """
172
+ Return a shallow-isolated copy of this client preserving provider,
173
+ options, middlewares, and tools without mutating ``AIClient._registry``.
174
+ """
175
+ cloned = AIClient(
176
+ config=self._config,
177
+ http_pool=self._http_pool,
178
+ default_provider=self._provider_name,
179
+ )
180
+ cloned._model = self._model
181
+ cloned._temperature = self._temperature
182
+ cloned._max_tokens = self._max_tokens
183
+ cloned._top_p = self._top_p
184
+ cloned._stop = list(self._stop) if self._stop is not None else None
185
+ cloned._extra = copy.deepcopy(self._extra)
186
+ cloned._metadata = copy.deepcopy(self._metadata)
187
+ cloned._tools = list(self._tools)
188
+ cloned._auto_execute_tools = self._auto_execute_tools
189
+ cloned._max_iterations = self._max_iterations
190
+ cloned._max_tool_calls = self._max_tool_calls
191
+ cloned._allowed_tools = (
192
+ tuple(self._allowed_tools) if self._allowed_tools is not None else None
193
+ )
194
+ cloned._tool_timeout = self._tool_timeout
195
+ cloned._max_total_tokens = self._max_total_tokens
196
+ cloned._max_cost = self._max_cost
197
+ cloned._middlewares = list(self._middlewares)
198
+ if include_messages:
199
+ cloned._messages = copy.deepcopy(self._messages)
200
+ if include_conversation:
201
+ cloned._attached_conversation = self._attached_conversation
202
+ cloned._pending_conversation_messages = copy.deepcopy(
203
+ self._pending_conversation_messages
204
+ )
205
+ return cloned
206
+
207
+ @classmethod
208
+ def register_provider(
209
+ cls,
210
+ name: str,
211
+ provider_cls: type[ProviderInterface] | ProviderInterface,
212
+ ) -> None:
213
+ """Register a custom provider adapter class or instance."""
214
+ cls._registry[name.lower()] = provider_cls
215
+
216
+ @classmethod
217
+ def use_global_middleware(cls, middleware: Any) -> None:
218
+ """Register global middleware applied to all AIClient instances."""
219
+ cls._global_middlewares.append(middleware)
220
+
221
+ @classmethod
222
+ def clear_global_middlewares(cls) -> None:
223
+ """Clear all registered global middlewares."""
224
+ cls._global_middlewares.clear()
225
+
226
+ def use(self, middleware: Any) -> Self:
227
+ """Attach a middleware layer to this client builder."""
228
+ self._middlewares.append(middleware)
229
+ return self
230
+
231
+ def middleware(self, middleware: Any) -> Self:
232
+ """Alias for use()."""
233
+ return self.use(middleware)
234
+
235
+ def _append_message(self, msg: Message) -> None:
236
+ """Append a message locally and buffer it if a Conversation is attached."""
237
+ self._messages.append(msg)
238
+ if self._attached_conversation is not None:
239
+ self._pending_conversation_messages.append(msg)
240
+
241
+ def _commit_conversation_turns(self, assistant_content: str | None = None) -> None:
242
+ """Persist buffered turn messages and optional assistant reply to the attached Conversation."""
243
+ if self._attached_conversation is None:
244
+ return
245
+ while self._pending_conversation_messages:
246
+ self._attached_conversation.add_message(
247
+ self._pending_conversation_messages.pop(0)
248
+ )
249
+ if assistant_content:
250
+ self._attached_conversation.add_assistant(assistant_content)
251
+
252
+ def prompt(self, name: str, /, role: str = "user", **variables: Any) -> Self:
253
+ """
254
+ Load a prompt template from disk, interpolate variables, and append as message.
255
+ """
256
+ rendered = load_prompt(name, **variables)
257
+ self._append_message(Message(role=role, content=rendered))
258
+ return self
259
+
260
+ async def aprompt(self, name: str, /, role: str = "user", **variables: Any) -> Self:
261
+ """
262
+ Asynchronously load a prompt template off the event loop, interpolate variables,
263
+ and append as message.
264
+ """
265
+ rendered = await aload_prompt(name, **variables)
266
+ self._append_message(Message(role=role, content=rendered))
267
+ return self
268
+
269
+ def system(self, content: str) -> Self:
270
+ """Add a system message prompt."""
271
+ self._append_message(Message(role="system", content=content))
272
+ return self
273
+
274
+ def user(self, content: str) -> Self:
275
+ """Add a user message prompt."""
276
+ self._append_message(Message(role="user", content=content))
277
+ return self
278
+
279
+ def assistant(self, content: str) -> Self:
280
+ """Add an assistant message to conversation history."""
281
+ self._messages.append(Message(role="assistant", content=content))
282
+ return self
283
+
284
+ def conversation(self, conv: Conversation) -> Self:
285
+ """
286
+ Attach a Conversation container to this client.
287
+
288
+ Pre-loads existing messages and automatically records new dialogue turns
289
+ (user prompt upon send/stream, and assistant reply upon successful completion).
290
+ """
291
+ self._attached_conversation = conv
292
+ for msg in conv.messages:
293
+ self._messages.append(msg)
294
+ return self
295
+
296
+ def history(self, messages: Sequence[Message | dict[str, str]]) -> Self:
297
+ """
298
+ Append conversation history to the existing messages in order.
299
+
300
+ Accepts Message instances or dictionaries containing 'role' and 'content' keys.
301
+ Empty sequences are accepted as a no-op without clearing earlier messages.
302
+ """
303
+ if not isinstance(messages, Sequence):
304
+ raise TypeError(
305
+ f"Expected a sequence for history, got {type(messages).__name__}"
306
+ )
307
+
308
+ for msg in messages:
309
+ if isinstance(msg, Message):
310
+ self._messages.append(msg)
311
+ elif isinstance(msg, dict):
312
+ role = str(msg.get("role", "user"))
313
+ content = str(msg.get("content", ""))
314
+ name = msg.get("name")
315
+ self._messages.append(Message(role=role, content=content, name=name))
316
+ else:
317
+ raise TypeError(
318
+ f"Expected Message or dict in history, got {type(msg).__name__}"
319
+ )
320
+ return self
321
+
322
+ def temperature(self, temp: float) -> Self:
323
+ """
324
+ Set sampling temperature.
325
+ Must be between 0.0 and 2.0 inclusive.
326
+ """
327
+ if not _is_real_number(temp) or not (0.0 <= float(temp) <= 2.0):
328
+ raise ValueError(f"Temperature must be between 0.0 and 2.0, got {temp}")
329
+ self._temperature = float(temp)
330
+ return self
331
+
332
+ def max_tokens(self, tokens: int) -> Self:
333
+ """
334
+ Set maximum tokens to generate.
335
+ Must be a positive integer greater than 0.
336
+ """
337
+ if not _is_real_int(tokens) or tokens <= 0:
338
+ raise ValueError(
339
+ f"max_tokens must be a positive integer greater than 0, got {tokens}"
340
+ )
341
+ self._max_tokens = tokens
342
+ return self
343
+
344
+ def top_p(self, p: float) -> Self:
345
+ """
346
+ Set nucleus sampling top_p parameter.
347
+ Must be between 0.0 and 1.0 inclusive.
348
+ """
349
+ if not _is_real_number(p) or not (0.0 <= float(p) <= 1.0):
350
+ raise ValueError(f"top_p must be between 0.0 and 1.0, got {p}")
351
+ self._top_p = float(p)
352
+ return self
353
+
354
+ def stop(self, sequences: list[str] | str) -> Self:
355
+ """
356
+ Set stop sequence(s).
357
+ Must be a non-empty string or a sequence of non-empty strings.
358
+ """
359
+ if isinstance(sequences, str):
360
+ if not sequences:
361
+ raise ValueError("Stop sequence string cannot be empty")
362
+ self._stop = [sequences]
363
+ elif isinstance(sequences, Sequence):
364
+ if not sequences:
365
+ raise ValueError("Stop sequence list cannot be empty")
366
+ parsed: list[str] = []
367
+ for item in sequences:
368
+ if not isinstance(item, str) or not item:
369
+ raise ValueError(
370
+ f"All stop sequence items must be non-empty strings, got {item!r}"
371
+ )
372
+ parsed.append(item)
373
+ self._stop = parsed
374
+ else:
375
+ raise TypeError(
376
+ f"Expected str or sequence of str for stop sequences, got {type(sequences).__name__}"
377
+ )
378
+ return self
379
+
380
+ def model(self, model_name: str) -> Self:
381
+ """Explicitly override the model used for this request."""
382
+ if not isinstance(model_name, str) or not model_name.strip():
383
+ raise ValueError("Model name must be a non-empty string")
384
+ self._model = model_name.strip()
385
+ return self
386
+
387
+ def provider(self, provider_name: str) -> Self:
388
+ """Explicitly override the provider adapter for this request."""
389
+ if not isinstance(provider_name, str) or not provider_name.strip():
390
+ raise ValueError("Provider name must be a non-empty string")
391
+ self._provider_name = provider_name.strip().lower()
392
+ return self
393
+
394
+ def metadata(self, **kwargs: Any) -> Self:
395
+ """Attach internal metadata (attribution/tracing) isolated from upstream payloads."""
396
+ self._metadata.update(kwargs)
397
+ return self
398
+
399
+ def extra(self, **kwargs: Any) -> Self:
400
+ """Pass additional provider-specific parameters."""
401
+ self._extra.update(kwargs)
402
+ return self
403
+
404
+ def tools(self, tools: Sequence[BaseTool | type[BaseTool]]) -> Self:
405
+ """
406
+ Register tools available for LLM tool calling.
407
+
408
+ Accepts instances of BaseTool or BaseTool subclasses (which are instantiated).
409
+ """
410
+ if not isinstance(tools, Sequence):
411
+ raise TypeError(f"Expected a sequence for tools, got {type(tools).__name__}")
412
+
413
+ resolved: list[BaseTool] = []
414
+ for t in tools:
415
+ if isinstance(t, type) and issubclass(t, BaseTool):
416
+ resolved.append(t())
417
+ elif isinstance(t, BaseTool):
418
+ resolved.append(t)
419
+ else:
420
+ raise TypeError(
421
+ f"Expected BaseTool instance or subclass, got {type(t).__name__}"
422
+ )
423
+ self._tools = resolved
424
+ return self
425
+
426
+ def auto_execute_tools(
427
+ self,
428
+ enable: bool = True,
429
+ max_iterations: int = 10,
430
+ max_tool_calls: int | None = None,
431
+ allowed_tools: Sequence[str] | None = None,
432
+ tool_timeout: float | None = None,
433
+ max_total_tokens: int | None = None,
434
+ max_cost: float | None = None,
435
+ ) -> Self:
436
+ """
437
+ Configure autonomous tool execution loop.
438
+
439
+ When enabled, calling .send() will automatically detect tool use responses from the
440
+ LLM, execute matching tool handlers concurrently, feed tool results back, and
441
+ repeat until a final text response is produced or configured limits are reached.
442
+ """
443
+ if not _is_real_int(max_iterations) or max_iterations <= 0:
444
+ raise ValueError(f"max_iterations must be a positive integer, got {max_iterations}")
445
+ if max_tool_calls is not None and (
446
+ not _is_real_int(max_tool_calls) or max_tool_calls <= 0
447
+ ):
448
+ raise ValueError(f"max_tool_calls must be a positive integer, got {max_tool_calls}")
449
+ if tool_timeout is not None and (
450
+ not _is_real_number(tool_timeout) or float(tool_timeout) <= 0
451
+ ):
452
+ raise ValueError(f"tool_timeout must be a positive float, got {tool_timeout}")
453
+ if max_total_tokens is not None and (
454
+ not _is_real_int(max_total_tokens) or max_total_tokens <= 0
455
+ ):
456
+ raise ValueError(
457
+ f"max_total_tokens must be a positive integer, got {max_total_tokens}"
458
+ )
459
+ if max_cost is not None and (
460
+ not _is_real_number(max_cost) or float(max_cost) <= 0
461
+ ):
462
+ raise ValueError(f"max_cost must be a positive float, got {max_cost}")
463
+
464
+ self._auto_execute_tools = bool(enable)
465
+ self._max_iterations = max_iterations
466
+ self._max_tool_calls = max_tool_calls
467
+ self._allowed_tools = allowed_tools
468
+ self._tool_timeout = tool_timeout
469
+ self._max_total_tokens = max_total_tokens
470
+ self._max_cost = float(max_cost) if max_cost is not None else None
471
+ return self
472
+
473
+ @property
474
+ def registered_tools(self) -> list[BaseTool]:
475
+ """Return a defensive copy of currently registered tools."""
476
+ return list(self._tools)
477
+
478
+ @property
479
+ def last_tool_loop(self) -> ToolLoop | None:
480
+ """Return the ToolLoop instance from the most recent autonomous execution."""
481
+ return self._last_tool_loop
482
+
483
+ def _resolve_provider(self) -> ProviderInterface:
484
+ # Check if AI module is enabled in configuration
485
+ is_enabled = self._config.get("ai.enabled", True)
486
+ if not is_enabled:
487
+ raise AIModuleDisabledError(
488
+ "AI module is disabled in configuration (ai.enabled is false)."
489
+ )
490
+
491
+ provider_name = (
492
+ self._provider_name
493
+ or str(self._config.get("ai.default", "openai"))
494
+ ).lower()
495
+
496
+ provider_cls = self._registry.get(provider_name)
497
+ if not provider_cls:
498
+ supported = ", ".join(sorted(self._registry.keys()))
499
+ raise AIConfigurationError(
500
+ f"Unknown LLM provider '{provider_name}'. Supported providers: {supported}"
501
+ )
502
+
503
+ raw_cfg = self._config.get(f"ai.providers.{provider_name}", {})
504
+ provider_cfg: dict[str, Any] = raw_cfg if isinstance(raw_cfg, dict) else {}
505
+
506
+ # Validate that an API key is configured for the chosen provider
507
+ api_key = str(provider_cfg.get("api_key", "")).strip()
508
+ if not api_key:
509
+ raise AIConfigurationError(
510
+ f"Missing API key for provider '{provider_name}'. "
511
+ f"Please configure 'ai.providers.{provider_name}.api_key' "
512
+ f"or set the appropriate environment variable."
513
+ )
514
+
515
+ if isinstance(provider_cls, type):
516
+ return provider_cls(config=provider_cfg, http_pool=self._http_pool)
517
+ return provider_cls
518
+
519
+ def _resolve_target_identity(self) -> tuple[str, str | None]:
520
+ """Resolve provider and model names from builder state/config without API key checks."""
521
+ provider_name = (
522
+ self._provider_name
523
+ or str(self._config.get("ai.default", "openai"))
524
+ ).lower()
525
+
526
+ model_name = self._model
527
+ if not model_name:
528
+ raw_cfg = self._config.get(f"ai.providers.{provider_name}", {})
529
+ if isinstance(raw_cfg, dict):
530
+ cfg_model = raw_cfg.get("model") or raw_cfg.get("default_model")
531
+ if isinstance(cfg_model, str) and cfg_model.strip():
532
+ model_name = cfg_model.strip()
533
+
534
+ if not model_name:
535
+ registered = self._registry.get(provider_name)
536
+ if registered is not None and not isinstance(registered, type):
537
+ model_name = registered.get_default_model()
538
+ else:
539
+ model_name = _DEFAULT_PROVIDER_MODELS.get(provider_name)
540
+
541
+ return provider_name, model_name
542
+
543
+ def _build_llm_request(self, *, include_tools: bool) -> tuple[str, str | None, LLMRequest]:
544
+ """Construct an LLMRequest with attribution metadata isolated from wire extra."""
545
+ provider_name, model_name = self._resolve_target_identity()
546
+ metadata_dict: dict[str, Any] = dict(self._metadata)
547
+ wire_extra: dict[str, Any] = {}
548
+ for key, value in self._extra.items():
549
+ if key in INTERNAL_ATTRIBUTION_KEYS:
550
+ metadata_dict.setdefault(key, value)
551
+ else:
552
+ wire_extra[key] = value
553
+ metadata_dict.setdefault("provider", provider_name)
554
+
555
+ request_tools: list[dict[str, Any]] | None = None
556
+ if include_tools and self._tools:
557
+ allowed_set = set(self._allowed_tools) if self._allowed_tools is not None else None
558
+ filtered = [
559
+ t.to_dict()
560
+ for t in self._tools
561
+ if allowed_set is None or t.name in allowed_set
562
+ ]
563
+ if filtered:
564
+ request_tools = filtered
565
+
566
+ request = LLMRequest(
567
+ messages=list(self._messages),
568
+ model=model_name,
569
+ temperature=self._temperature,
570
+ max_tokens=self._max_tokens,
571
+ top_p=self._top_p,
572
+ stop=self._stop,
573
+ tools=request_tools,
574
+ extra=wire_extra,
575
+ metadata=metadata_dict,
576
+ )
577
+ return provider_name, model_name, request
578
+
579
+ def _build_pipeline(self) -> AIMiddlewarePipeline:
580
+ """Assemble global and client-scoped AI middleware in order."""
581
+ return AIMiddlewarePipeline(
582
+ list(self._global_middlewares) + list(self._middlewares)
583
+ )
584
+
585
+ async def send(self) -> LLMResponse:
586
+ """
587
+ Dispatch the completion request to the active LLM provider.
588
+ """
589
+ if not self._messages:
590
+ raise ValueError("Cannot send request: at least one message is required.")
591
+
592
+ # If auto_execute_tools is enabled and tools are registered, delegate to ToolLoop
593
+ if self._auto_execute_tools and self._tools and not self._is_executing_tool_loop:
594
+ self._is_executing_tool_loop = True
595
+ try:
596
+ loop = ToolLoop(
597
+ client=self,
598
+ tools=self._tools,
599
+ max_iterations=self._max_iterations,
600
+ max_tool_calls=self._max_tool_calls,
601
+ allowed_tools=self._allowed_tools,
602
+ tool_timeout=self._tool_timeout,
603
+ max_total_tokens=self._max_total_tokens,
604
+ max_cost=self._max_cost,
605
+ )
606
+ self._last_tool_loop = loop
607
+ final_response = await loop.run()
608
+ self._commit_conversation_turns(final_response.content)
609
+ return final_response
610
+ finally:
611
+ self._is_executing_tool_loop = False
612
+
613
+ _provider_name, _model_name, request = self._build_llm_request(
614
+ include_tools=not self._suppress_tools_in_request
615
+ )
616
+ pipeline = self._build_pipeline()
617
+
618
+ active_fake = AIFake.get_active()
619
+ if active_fake is not None:
620
+ async def fake_terminal(req: LLMRequest) -> LLMResponse:
621
+ return await active_fake.handle_send(self, req)
622
+
623
+ response = await pipeline.execute(request, fake_terminal)
624
+ if not self._is_executing_tool_loop:
625
+ self._commit_conversation_turns(response.content)
626
+ return response
627
+
628
+ observer = get_observability()
629
+
630
+ async def provider_terminal(req: LLMRequest) -> LLMResponse:
631
+ provider = self._resolve_provider()
632
+ obs_model = req.model or provider.get_default_model()
633
+ async with observer.observe_call(
634
+ model=obs_model,
635
+ provider=provider.name,
636
+ messages=req.messages,
637
+ metadata={**dict(req.extra), **dict(req.metadata)},
638
+ ) as obs_ctx:
639
+ resp = await provider.send(req)
640
+ obs_ctx.set_response(resp)
641
+ return resp
642
+
643
+ response = await pipeline.execute(request, provider_terminal)
644
+
645
+ # Record user/assistant completion in attached conversation only after success
646
+ if not self._is_executing_tool_loop:
647
+ self._commit_conversation_turns(response.content)
648
+
649
+ return response
650
+
651
+ async def stream(self) -> AsyncIterator[str]:
652
+ """
653
+ Dispatch the streaming completion request through middleware to the active provider.
654
+
655
+ Yields string token chunks incrementally as they arrive over SSE.
656
+ If an attached Conversation is present, persists buffered prompt turns once
657
+ streaming begins cleanly and appends the assistant reply only on completion.
658
+ """
659
+ if not self._messages:
660
+ raise ValueError("Cannot stream request: at least one message is required.")
661
+
662
+ _provider_name, model_name, request = self._build_llm_request(include_tools=False)
663
+ pipeline = self._build_pipeline()
664
+ active_fake = AIFake.get_active()
665
+
666
+ done_sentinel = object()
667
+ chunk_queue: asyncio.Queue[object] = asyncio.Queue()
668
+ chunk_consumed = asyncio.Event()
669
+ abort_stream = asyncio.Event()
670
+ terminal_started = False
671
+ stream_completed = False
672
+ accumulated_chunks: list[str] = []
673
+
674
+ async def _pump_chunks(
675
+ gen: AsyncIterator[str],
676
+ on_first_chunk: Any | None = None,
677
+ ) -> bool:
678
+ nonlocal stream_completed
679
+ first = True
680
+ gen_iter = gen.__aiter__()
681
+ while not abort_stream.is_set():
682
+ try:
683
+ aborted, chunk = await _await_or_abort(
684
+ _next_stream_chunk(gen_iter),
685
+ abort_stream,
686
+ )
687
+ except StopAsyncIteration:
688
+ stream_completed = True
689
+ break
690
+ if aborted:
691
+ break
692
+ if first and on_first_chunk is not None:
693
+ on_first_chunk()
694
+ first = False
695
+ chunk_text = str(chunk)
696
+ accumulated_chunks.append(chunk_text)
697
+ chunk_consumed.clear()
698
+ chunk_queue.put_nowait(chunk_text)
699
+ aborted, _ = await _await_or_abort(chunk_consumed.wait(), abort_stream)
700
+ if aborted:
701
+ break
702
+ return stream_completed
703
+
704
+ async def stream_terminal(req: LLMRequest) -> LLMResponse:
705
+ nonlocal terminal_started
706
+ terminal_started = True
707
+ prompt_chars = sum(len(m.content or "") for m in req.messages)
708
+ input_tokens = 0 if prompt_chars == 0 else max(1, prompt_chars // 4)
709
+
710
+ if active_fake is not None:
711
+ gen = active_fake.handle_stream(self, req)
712
+ try:
713
+ await _pump_chunks(gen)
714
+ finally:
715
+ if not stream_completed and isinstance(gen, AsyncGenerator):
716
+ await gen.aclose()
717
+ full_text = "".join(accumulated_chunks)
718
+ output_tokens = _estimate_token_count(full_text)
719
+ return LLMResponse(
720
+ content=full_text,
721
+ model=req.model or model_name or "fake-model",
722
+ usage=TokenUsage(
723
+ prompt_tokens=input_tokens,
724
+ completion_tokens=output_tokens,
725
+ total_tokens=input_tokens + output_tokens,
726
+ ),
727
+ finish_reason="stop" if stream_completed else "cancelled",
728
+ )
729
+
730
+ provider = self._resolve_provider()
731
+ observer = get_observability()
732
+ obs_model = req.model or provider.get_default_model()
733
+ prompt_hash = compute_prompt_hash(req.messages)
734
+ obs_ctx = ObservationContext(
735
+ model=obs_model,
736
+ provider=provider.name,
737
+ prompt_hash=prompt_hash,
738
+ metadata={"stream": True, **dict(req.extra), **dict(req.metadata)},
739
+ )
740
+ stream_start = time.perf_counter()
741
+ gen = provider.stream(req)
742
+ try:
743
+ await _pump_chunks(
744
+ gen,
745
+ on_first_chunk=lambda: obs_ctx.record_first_token(stream_start),
746
+ )
747
+ except (asyncio.CancelledError, GeneratorExit):
748
+ obs_ctx.status = "cancelled"
749
+ obs_ctx.error_message = "Stream consumer disconnected or cancelled."
750
+ raise
751
+ except Exception as exc:
752
+ obs_ctx.status = "error"
753
+ obs_ctx.error_message = str(exc)
754
+ raise
755
+ finally:
756
+ if not stream_completed and isinstance(gen, AsyncGenerator):
757
+ await gen.aclose()
758
+ latency_ms = (time.perf_counter() - stream_start) * 1000.0
759
+ obs_ctx.duration_ms = latency_ms
760
+ if not stream_completed and obs_ctx.status == "success":
761
+ obs_ctx.status = "cancelled"
762
+ full_text = "".join(accumulated_chunks)
763
+ output_tokens = _estimate_token_count(full_text)
764
+ obs_ctx.set_tokens(input_tokens, output_tokens)
765
+ await observer.record(
766
+ model=obs_ctx.model,
767
+ provider=obs_ctx.provider,
768
+ input_tokens=obs_ctx.input_tokens,
769
+ output_tokens=obs_ctx.output_tokens,
770
+ latency_ms=latency_ms,
771
+ prompt_hash=obs_ctx.prompt_hash,
772
+ cost=obs_ctx.cost,
773
+ status=obs_ctx.status,
774
+ error_message=obs_ctx.error_message,
775
+ metadata=obs_ctx.metadata,
776
+ ttft_ms=obs_ctx.ttft_ms,
777
+ duration_ms=obs_ctx.duration_ms,
778
+ )
779
+
780
+ return LLMResponse(
781
+ content=full_text,
782
+ model=obs_model,
783
+ usage=TokenUsage(
784
+ prompt_tokens=input_tokens,
785
+ completion_tokens=output_tokens,
786
+ total_tokens=input_tokens + output_tokens,
787
+ ),
788
+ finish_reason="stop" if stream_completed else "cancelled",
789
+ )
790
+
791
+ async def _run_pipeline() -> LLMResponse:
792
+ try:
793
+ return await pipeline.execute(request, stream_terminal)
794
+ finally:
795
+ chunk_queue.put_nowait(done_sentinel)
796
+
797
+ pipeline_task = asyncio.create_task(_run_pipeline())
798
+ pipeline_task.add_done_callback(_consume_task_exception)
799
+
800
+ try:
801
+ while True:
802
+ item = await chunk_queue.get()
803
+ if item is done_sentinel:
804
+ break
805
+ chunk_str = str(item)
806
+ chunk_consumed.set()
807
+ self._commit_conversation_turns(None)
808
+ yield chunk_str
809
+
810
+ pipeline_response = await pipeline_task
811
+ if not terminal_started:
812
+ self._commit_conversation_turns(None)
813
+ if pipeline_response.content:
814
+ yield pipeline_response.content
815
+ self._commit_conversation_turns(pipeline_response.content)
816
+ else:
817
+ self._commit_conversation_turns("".join(accumulated_chunks))
818
+ except (asyncio.CancelledError, GeneratorExit):
819
+ abort_stream.set()
820
+ if not pipeline_task.done():
821
+ with contextlib.suppress(Exception, asyncio.CancelledError):
822
+ await pipeline_task
823
+ raise
824
+ finally:
825
+ abort_stream.set()
826
+ if not pipeline_task.done():
827
+ with contextlib.suppress(Exception, asyncio.CancelledError):
828
+ await pipeline_task
829
+
830
+
831
+ class AI:
832
+ """
833
+ High-level ergonomic facade for the SwiftPY AI native module.
834
+
835
+ Target usage:
836
+ @app.post("/chat/stream")
837
+ async def chat_stream(body: ChatRequest) -> Response:
838
+ async def tokens():
839
+ async for chunk in (
840
+ AI.chat("claude-sonnet-4-6")
841
+ .system("You are a helpful assistant.")
842
+ .user(body.message)
843
+ .stream()
844
+ ):
845
+ yield chunk
846
+ return stream(tokens())
847
+ """
848
+
849
+ @classmethod
850
+ def fake(cls, fixtures: FixtureType | None = None) -> AIFake:
851
+ """
852
+ Activate an AIFake test double that intercepts all LLM calls across the application.
853
+ Zero real external API calls are made while active.
854
+ """
855
+ return AIFake(fixtures).activate()
856
+
857
+ @classmethod
858
+ def restore(cls) -> None:
859
+ """Deactivate active test fake and restore normal provider execution."""
860
+ active = AIFake.get_active()
861
+ if active is not None:
862
+ active.restore()
863
+
864
+ @classmethod
865
+ def add_middleware(cls, middleware: Any) -> None:
866
+ """Register application-wide AI middleware."""
867
+ AIClient.use_global_middleware(middleware)
868
+
869
+ @classmethod
870
+ def prompt(
871
+ cls,
872
+ name: str,
873
+ /,
874
+ model: str | None = None,
875
+ provider: str | None = None,
876
+ **variables: Any,
877
+ ) -> AIClient:
878
+ """Start a fluent AIClient initialized with the specified prompt template."""
879
+ client = cls.chat(model=model, provider=provider)
880
+ client.prompt(name, **variables)
881
+ return client
882
+
883
+ @classmethod
884
+ async def aprompt(
885
+ cls,
886
+ name: str,
887
+ /,
888
+ model: str | None = None,
889
+ provider: str | None = None,
890
+ **variables: Any,
891
+ ) -> AIClient:
892
+ """Start a fluent AIClient asynchronously initialized with the specified prompt template."""
893
+ client = cls.chat(model=model, provider=provider)
894
+ await client.aprompt(name, **variables)
895
+ return client
896
+
897
+ @classmethod
898
+ def chat(
899
+ cls,
900
+ model: str | None = None,
901
+ provider: str | None = None,
902
+ config: Config | None = None,
903
+ http_pool: AIHttpPool | None = None,
904
+ ) -> AIClient:
905
+ """Create a fluent AIClient, automatically inferring provider from model when possible."""
906
+ client = AIClient(config=config, http_pool=http_pool)
907
+ if model:
908
+ client.model(model)
909
+ if not provider:
910
+ lower_model = model.lower()
911
+ if "claude" in lower_model:
912
+ client.provider("anthropic")
913
+ elif any(p in lower_model for p in ("gpt", "o1", "o3", "davinci", "text-")):
914
+ client.provider("openai")
915
+ if provider:
916
+ client.provider(provider)
917
+ return client
918
+
919
+ @classmethod
920
+ def client(
921
+ cls,
922
+ config: Config | None = None,
923
+ http_pool: AIHttpPool | None = None,
924
+ default_provider: str | None = None,
925
+ ) -> AIClient:
926
+ """Return a new AIClient builder."""
927
+ return AIClient(
928
+ config=config,
929
+ http_pool=http_pool,
930
+ default_provider=default_provider,
931
+ )
932
+
933
+ @classmethod
934
+ async def embed(
935
+ cls,
936
+ text: str,
937
+ model: str | None = None,
938
+ provider: str | None = None,
939
+ config: Config | None = None,
940
+ http_pool: AIHttpPool | None = None,
941
+ ) -> list[float]:
942
+ """Generate an embedding vector for a single text."""
943
+ client = EmbeddingsClient(config=config, http_pool=http_pool)
944
+ return await client.embed(text, model=model, provider=provider)
945
+
946
+ @classmethod
947
+ async def embed_batch(
948
+ cls,
949
+ texts: Sequence[str],
950
+ model: str | None = None,
951
+ provider: str | None = None,
952
+ chunk_size: int = 100,
953
+ concurrency: int = 5,
954
+ config: Config | None = None,
955
+ http_pool: AIHttpPool | None = None,
956
+ ) -> list[list[float]]:
957
+ """
958
+ Embed a collection of texts with automatic batch chunking and concurrency.
959
+ """
960
+ client = EmbeddingsClient(config=config, http_pool=http_pool)
961
+ return await client.embed_batch(
962
+ texts,
963
+ model=model,
964
+ provider=provider,
965
+ chunk_size=chunk_size,
966
+ concurrency=concurrency,
967
+ )
968
+
969
+ @classmethod
970
+ def vectorstore(
971
+ cls,
972
+ adapter: str = "in_memory",
973
+ collection: str = "default",
974
+ dimensions: int = 1536,
975
+ metric: str = "cosine",
976
+ **kwargs: Any,
977
+ ) -> VectorStore:
978
+ """
979
+ Instantiate a VectorStore adapter ('in_memory', 'pgvector', 'qdrant', 'lancedb', 'chroma', 'pinecone').
980
+ """
981
+ norm_adapter = adapter.lower().strip()
982
+ if norm_adapter in ("in_memory", "memory"):
983
+ return VectorStore.in_memory(collection=collection, dimensions=dimensions, metric=metric, **kwargs)
984
+ if norm_adapter in ("pgvector", "postgres", "postgresql"):
985
+ return VectorStore.pgvector(collection=collection, dimensions=dimensions, metric=metric, **kwargs)
986
+ if norm_adapter == "qdrant":
987
+ return VectorStore.qdrant(collection=collection, dimensions=dimensions, metric=metric, **kwargs)
988
+ if norm_adapter == "lancedb":
989
+ return VectorStore.lancedb(collection=collection, dimensions=dimensions, metric=metric, **kwargs)
990
+ if norm_adapter == "chroma":
991
+ return VectorStore.chroma(collection=collection, dimensions=dimensions, metric=metric, **kwargs)
992
+ if norm_adapter == "pinecone":
993
+ return VectorStore.pinecone(collection=collection, dimensions=dimensions, metric=metric, **kwargs)
994
+
995
+ raise ValueError(
996
+ f"Unknown vectorstore adapter '{adapter}'. "
997
+ f"Expected one of 'in_memory', 'pgvector', 'qdrant', 'lancedb', 'chroma', 'pinecone'."
998
+ )
999
+
1000
+
1001
+ def ai(
1002
+ config: Config | None = None,
1003
+ http_pool: AIHttpPool | None = None,
1004
+ default_provider: str | None = None,
1005
+ ) -> AIClient:
1006
+ """Convenience factory returning a new fluent AIClient builder."""
1007
+ return AIClient(
1008
+ config=config,
1009
+ http_pool=http_pool,
1010
+ default_provider=default_provider,
1011
+ )