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/_fake.py ADDED
@@ -0,0 +1,604 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextvars
5
+ import time
6
+ import types
7
+ from collections.abc import AsyncIterator, Callable, Mapping, Sequence
8
+ from dataclasses import dataclass, field
9
+ from datetime import UTC, datetime
10
+ from typing import TYPE_CHECKING, Any, ClassVar, Self
11
+
12
+ from swiftpy.ai._deterministic import generate_deterministic_embedding
13
+ from swiftpy.ai.observability.observer import (
14
+ ObservationContext,
15
+ compute_prompt_hash,
16
+ get_observability,
17
+ )
18
+ from swiftpy.ai.providers.base import (
19
+ LLMRequest,
20
+ LLMResponse,
21
+ Message,
22
+ TokenUsage,
23
+ )
24
+
25
+ if TYPE_CHECKING:
26
+ from swiftpy.ai.client import AIClient
27
+
28
+ FixtureType = (
29
+ str
30
+ | LLMResponse
31
+ | Sequence[str | LLMResponse]
32
+ | Mapping[str, str | LLMResponse]
33
+ | Callable[[LLMRequest], str | LLMResponse]
34
+ )
35
+
36
+ _task_fake_stack: contextvars.ContextVar[tuple[AIFake, ...]] = contextvars.ContextVar(
37
+ "_swiftpy_ai_fake_stack", default=()
38
+ )
39
+
40
+
41
+ @dataclass
42
+ class FakeCall:
43
+ """Telemetry record for an intercepted fake LLM call."""
44
+
45
+ request: LLMRequest
46
+ messages: list[Message]
47
+ model: str | None
48
+ provider: str | None
49
+ temperature: float | None
50
+ max_tokens: int | None
51
+ response: LLMResponse
52
+ timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
53
+
54
+ @property
55
+ def prompt_text(self) -> str:
56
+ """Concatenated contents of all request messages."""
57
+ return "\n".join(m.content or "" for m in self.messages)
58
+
59
+ @property
60
+ def user_content(self) -> str:
61
+ """Content of the most recent user message."""
62
+ for m in reversed(self.messages):
63
+ if m.role == "user":
64
+ return m.content or ""
65
+ return ""
66
+
67
+ @property
68
+ def system_content(self) -> str:
69
+ """Content of the most recent system message."""
70
+ for m in reversed(self.messages):
71
+ if m.role == "system":
72
+ return m.content or ""
73
+ return ""
74
+
75
+
76
+ @dataclass
77
+ class FakeEmbeddingCall:
78
+ """Telemetry record for an intercepted fake embedding call."""
79
+
80
+ texts: list[str]
81
+ model: str | None
82
+ provider: str | None
83
+ dimensions: int
84
+ timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
85
+
86
+
87
+ class AIFake:
88
+ """
89
+ Test fake that intercepts all LLM and embedding calls across the application.
90
+ Returns configured canned fixtures, records all invocations,
91
+ triggers observability telemetry without making real external API calls,
92
+ isolates calls per asyncio task via ContextVar, and exposes expressive assertion helpers.
93
+ """
94
+
95
+ _global_fake: ClassVar[AIFake | None] = None
96
+
97
+ def __init__(
98
+ self,
99
+ fixtures: FixtureType | None = None,
100
+ embedding_fixtures: (
101
+ Mapping[str, list[float]]
102
+ | list[list[float]]
103
+ | Callable[[Sequence[str]], list[list[float]]]
104
+ | None
105
+ ) = None,
106
+ ) -> None:
107
+ self._fixtures = fixtures
108
+ self._embedding_fixtures = embedding_fixtures
109
+ self._queue: list[str | LLMResponse] = []
110
+ self._calls: list[FakeCall] = []
111
+ self._embedding_calls: list[FakeEmbeddingCall] = []
112
+ self._is_active = False
113
+
114
+ self._init_fixtures(fixtures)
115
+
116
+ def _init_fixtures(self, fixtures: FixtureType | None) -> None:
117
+ if isinstance(fixtures, (list, tuple)):
118
+ self._queue = list(fixtures)
119
+ else:
120
+ self._queue = []
121
+
122
+ @classmethod
123
+ def get_active(cls) -> AIFake | None:
124
+ """
125
+ Return the currently active fake instance.
126
+ Resolves task-local active fake first, falling back to global fake if set.
127
+ """
128
+ stack = _task_fake_stack.get()
129
+ if stack:
130
+ return stack[-1]
131
+ return cls._global_fake
132
+
133
+ @classmethod
134
+ def set_active(cls, fake: AIFake | None) -> None:
135
+ """Set the active fake instance globally and in current task context."""
136
+ cls._global_fake = fake
137
+ if fake is None:
138
+ _task_fake_stack.set(())
139
+ else:
140
+ stack = _task_fake_stack.get()
141
+ if fake not in stack:
142
+ _task_fake_stack.set((*stack, fake))
143
+
144
+ def activate(self) -> Self:
145
+ """Activate interception of all LLM and embedding calls in current task."""
146
+ stack = _task_fake_stack.get()
147
+ if self not in stack:
148
+ _task_fake_stack.set((*stack, self))
149
+ self._is_active = True
150
+ return self
151
+
152
+ def restore(self) -> None:
153
+ """Deactivate interception and restore previous active fake or live execution."""
154
+ stack = _task_fake_stack.get()
155
+ if self in stack:
156
+ idx = len(stack) - 1 - stack[::-1].index(self)
157
+ _task_fake_stack.set(stack[:idx] + stack[idx + 1 :])
158
+ if AIFake._global_fake is self:
159
+ AIFake._global_fake = None
160
+ self._is_active = False
161
+
162
+ def reset(self) -> None:
163
+ """Clear recorded calls and reset fixture queue."""
164
+ self._calls.clear()
165
+ self._embedding_calls.clear()
166
+ self._init_fixtures(self._fixtures)
167
+
168
+ def __enter__(self) -> Self:
169
+ return self.activate()
170
+
171
+ def __exit__(
172
+ self,
173
+ exc_type: type[BaseException] | None,
174
+ exc_val: BaseException | None,
175
+ exc_tb: types.TracebackType | None,
176
+ ) -> None:
177
+ self.restore()
178
+
179
+ async def __aenter__(self) -> Self:
180
+ return self.__enter__()
181
+
182
+ async def __aexit__(
183
+ self,
184
+ exc_type: type[BaseException] | None,
185
+ exc_val: BaseException | None,
186
+ exc_tb: types.TracebackType | None,
187
+ ) -> None:
188
+ self.__exit__(exc_type, exc_val, exc_tb)
189
+
190
+ @property
191
+ def calls(self) -> list[FakeCall]:
192
+ """Defensive copy of recorded LLM calls."""
193
+ return list(self._calls)
194
+
195
+ @property
196
+ def call_count(self) -> int:
197
+ """Total number of intercepted LLM calls."""
198
+ return len(self._calls)
199
+
200
+ @property
201
+ def embedding_calls(self) -> list[FakeEmbeddingCall]:
202
+ """Defensive copy of recorded embedding calls."""
203
+ return list(self._embedding_calls)
204
+
205
+ @property
206
+ def embedding_call_count(self) -> int:
207
+ """Total number of intercepted embedding calls."""
208
+ return len(self._embedding_calls)
209
+
210
+ def _coerce_fixture(
211
+ self,
212
+ value: str | LLMResponse,
213
+ model_name: str,
214
+ provider: str,
215
+ ) -> LLMResponse:
216
+ """Convert a fixture string or LLMResponse into an LLMResponse instance."""
217
+ if isinstance(value, LLMResponse):
218
+ return value
219
+ return self._build_response(str(value), model_name, provider)
220
+
221
+ def _resolve_call_identity(
222
+ self,
223
+ client: AIClient,
224
+ request: LLMRequest,
225
+ ) -> tuple[str, str]:
226
+ """Resolve effective provider and model names for a faked call."""
227
+ provider_name = client._provider_name or str(
228
+ request.metadata.get("provider") or "fake_provider"
229
+ )
230
+ model_name = request.model or client._model or "fake-model"
231
+ return provider_name, model_name
232
+
233
+ def _generate_response(self, request: LLMRequest, provider: str) -> LLMResponse:
234
+ """Resolve next canned response for the given request."""
235
+ model_name = request.model or "fake-model"
236
+
237
+ if callable(self._fixtures):
238
+ return self._coerce_fixture(self._fixtures(request), model_name, provider)
239
+
240
+ if isinstance(self._fixtures, Mapping):
241
+ model_lower = model_name.lower()
242
+ # 1. Exact model match first
243
+ for key, val in self._fixtures.items():
244
+ key_lower = key.lower()
245
+ if key_lower not in ("*", "default") and key_lower == model_lower:
246
+ return self._coerce_fixture(val, model_name, provider)
247
+ # 2. Longest model prefix / substring match next
248
+ sorted_candidates = sorted(
249
+ (
250
+ (key, val)
251
+ for key, val in self._fixtures.items()
252
+ if key and key.lower() not in ("*", "default")
253
+ ),
254
+ key=lambda item: len(item[0]),
255
+ reverse=True,
256
+ )
257
+ for key, val in sorted_candidates:
258
+ if key.lower() in model_lower:
259
+ return self._coerce_fixture(val, model_name, provider)
260
+ # 3. Provider match (case-insensitive)
261
+ provider_lower = provider.lower()
262
+ for key, val in self._fixtures.items():
263
+ if key.lower() == provider_lower:
264
+ return self._coerce_fixture(val, model_name, provider)
265
+ # 4. Default fallback key if provided
266
+ fallback_val = self._fixtures.get("*")
267
+ if fallback_val is None:
268
+ fallback_val = self._fixtures.get("default")
269
+ if fallback_val is not None:
270
+ return self._coerce_fixture(fallback_val, model_name, provider)
271
+
272
+ if self._queue:
273
+ return self._coerce_fixture(self._queue.pop(0), model_name, provider)
274
+
275
+ if isinstance(self._fixtures, (str, LLMResponse)):
276
+ return self._coerce_fixture(self._fixtures, model_name, provider)
277
+
278
+ # Default fallback response
279
+ return self._build_response(
280
+ "Fake LLM completion response.",
281
+ model_name,
282
+ provider,
283
+ )
284
+
285
+ def _build_response(self, content: str, model: str, provider: str) -> LLMResponse:
286
+ prompt_tokens = 10
287
+ completion_tokens = max(1, len(content) // 4)
288
+ return LLMResponse(
289
+ content=content,
290
+ model=model,
291
+ usage=TokenUsage(
292
+ prompt_tokens=prompt_tokens,
293
+ completion_tokens=completion_tokens,
294
+ total_tokens=prompt_tokens + completion_tokens,
295
+ ),
296
+ finish_reason="stop",
297
+ raw={"faked": True, "provider": provider},
298
+ )
299
+
300
+ async def handle_send(self, client: AIClient, request: LLMRequest) -> LLMResponse:
301
+ """
302
+ Intercept AIClient.send() call, record call, notify observability,
303
+ and return canned response.
304
+ """
305
+ provider_name, model_name = self._resolve_call_identity(client, request)
306
+
307
+ observer = get_observability()
308
+ async with observer.observe_call(
309
+ model=model_name,
310
+ provider=provider_name,
311
+ messages=request.messages,
312
+ metadata={"faked": True, **dict(request.extra), **dict(request.metadata)},
313
+ ) as obs_ctx:
314
+ # Simulate a brief asynchronous pause
315
+ await asyncio.sleep(0.001)
316
+
317
+ response = self._generate_response(request, provider_name)
318
+ obs_ctx.set_response(response)
319
+
320
+ record = FakeCall(
321
+ request=request,
322
+ messages=list(request.messages),
323
+ model=model_name,
324
+ provider=provider_name,
325
+ temperature=request.temperature,
326
+ max_tokens=request.max_tokens,
327
+ response=response,
328
+ )
329
+ self._calls.append(record)
330
+
331
+ return response
332
+
333
+ async def handle_stream(
334
+ self, client: AIClient, request: LLMRequest
335
+ ) -> AsyncIterator[str]:
336
+ """
337
+ Intercept AIClient.stream() call, record call, notify observability,
338
+ and yield canned tokens incrementally with TTFT tracking.
339
+ """
340
+ provider_name, model_name = self._resolve_call_identity(client, request)
341
+
342
+ observer = get_observability()
343
+ prompt_hash = compute_prompt_hash(request.messages)
344
+ obs_ctx = ObservationContext(
345
+ model=model_name,
346
+ provider=provider_name,
347
+ prompt_hash=prompt_hash,
348
+ metadata={
349
+ "faked": True,
350
+ "stream": True,
351
+ **dict(request.extra),
352
+ **dict(request.metadata),
353
+ },
354
+ )
355
+ response = self._generate_response(request, provider_name)
356
+ obs_ctx.set_response(response)
357
+
358
+ record = FakeCall(
359
+ request=request,
360
+ messages=list(request.messages),
361
+ model=model_name,
362
+ provider=provider_name,
363
+ temperature=request.temperature,
364
+ max_tokens=request.max_tokens,
365
+ response=response,
366
+ )
367
+ self._calls.append(record)
368
+
369
+ content = response.content or ""
370
+ words = content.split(" ") if content else []
371
+ stream_start = time.perf_counter()
372
+ first_chunk = True
373
+ completed = False
374
+
375
+ try:
376
+ for i, word in enumerate(words):
377
+ chunk = word if i == len(words) - 1 else f"{word} "
378
+ await asyncio.sleep(0.001)
379
+ if first_chunk:
380
+ obs_ctx.record_first_token(stream_start)
381
+ first_chunk = False
382
+ yield chunk
383
+ completed = True
384
+ except (asyncio.CancelledError, GeneratorExit) as exc:
385
+ obs_ctx.status = "cancelled"
386
+ obs_ctx.error_message = str(exc) or "Stream consumer disconnected or cancelled."
387
+ raise
388
+ except Exception as exc:
389
+ obs_ctx.status = "error"
390
+ obs_ctx.error_message = str(exc)
391
+ raise
392
+ finally:
393
+ latency_ms = (time.perf_counter() - stream_start) * 1000.0
394
+ obs_ctx.duration_ms = latency_ms
395
+ if not completed and obs_ctx.status == "success":
396
+ obs_ctx.status = "cancelled"
397
+ await observer.record(
398
+ model=obs_ctx.model,
399
+ provider=obs_ctx.provider,
400
+ input_tokens=obs_ctx.input_tokens,
401
+ output_tokens=obs_ctx.output_tokens,
402
+ latency_ms=latency_ms,
403
+ prompt_hash=obs_ctx.prompt_hash,
404
+ cost=obs_ctx.cost,
405
+ status=obs_ctx.status,
406
+ error_message=obs_ctx.error_message,
407
+ metadata=obs_ctx.metadata,
408
+ ttft_ms=obs_ctx.ttft_ms,
409
+ duration_ms=obs_ctx.duration_ms,
410
+ )
411
+
412
+ async def handle_embed(
413
+ self,
414
+ texts: Sequence[str],
415
+ model: str | None = None,
416
+ provider: str | None = None,
417
+ dimensions: int = 1536,
418
+ ) -> list[list[float]]:
419
+ """
420
+ Intercept embedding call, record call in telemetry, and return deterministic or fixture vectors.
421
+ """
422
+ text_list = list(texts)
423
+ call = FakeEmbeddingCall(
424
+ texts=text_list,
425
+ model=model,
426
+ provider=provider,
427
+ dimensions=dimensions,
428
+ )
429
+ self._embedding_calls.append(call)
430
+
431
+ if callable(self._embedding_fixtures):
432
+ return self._embedding_fixtures(text_list)
433
+
434
+ if isinstance(self._embedding_fixtures, Mapping):
435
+ vectors: list[list[float]] = []
436
+ for t in text_list:
437
+ if t in self._embedding_fixtures:
438
+ vectors.append(list(self._embedding_fixtures[t]))
439
+ elif "*" in self._embedding_fixtures:
440
+ vectors.append(list(self._embedding_fixtures["*"]))
441
+ else:
442
+ vectors.append(generate_deterministic_embedding(t, dimensions=dimensions))
443
+ return vectors
444
+
445
+ if isinstance(self._embedding_fixtures, list) and self._embedding_fixtures:
446
+ fixture_count = len(self._embedding_fixtures)
447
+ return [
448
+ list(self._embedding_fixtures[i % fixture_count])
449
+ for i in range(len(text_list))
450
+ ]
451
+
452
+ return [generate_deterministic_embedding(t, dimensions=dimensions) for t in text_list]
453
+
454
+ # --------------------------------------------------------------------------
455
+ # Assertion Helpers
456
+ # --------------------------------------------------------------------------
457
+
458
+ def assert_called(self, times: int | None = None) -> None:
459
+ """
460
+ Assert that the fake was invoked at least once, or exactly `times` times.
461
+ """
462
+ count = len(self._calls)
463
+ if times is None:
464
+ if count == 0:
465
+ raise AssertionError("Expected AI to be called, but zero calls were made.")
466
+ elif count != times:
467
+ raise AssertionError(
468
+ f"Expected AI to be called exactly {times} times, but was called {count} times."
469
+ )
470
+
471
+ def assert_called_once(self) -> None:
472
+ """Assert that the fake was invoked exactly once."""
473
+ self.assert_called(times=1)
474
+
475
+ def assert_not_called(self) -> None:
476
+ """Assert that no AI calls were made."""
477
+ self.assert_called(times=0)
478
+
479
+ def assert_called_with(
480
+ self,
481
+ *,
482
+ model: str | None = None,
483
+ provider: str | None = None,
484
+ prompt: str | None = None,
485
+ user: str | None = None,
486
+ system: str | None = None,
487
+ messages: Sequence[Message | dict[str, Any]] | None = None,
488
+ ) -> None:
489
+ """
490
+ Assert that at least one recorded call matched all specified parameters.
491
+ """
492
+ if not self._calls:
493
+ raise AssertionError(
494
+ "Expected AI call matching criteria, but zero calls were made."
495
+ )
496
+
497
+ matched = False
498
+ reasons: list[str] = []
499
+
500
+ for idx, call in enumerate(self._calls):
501
+ mismatch = []
502
+ if model is not None and call.model != model:
503
+ mismatch.append(f"model: expected '{model}', got '{call.model}'")
504
+ if provider is not None and call.provider != provider:
505
+ mismatch.append(f"provider: expected '{provider}', got '{call.provider}'")
506
+ if prompt is not None and prompt not in call.prompt_text:
507
+ mismatch.append(f"prompt substring '{prompt}' not found in '{call.prompt_text}'")
508
+ if user is not None and user not in call.user_content:
509
+ mismatch.append(f"user content '{user}' not found in '{call.user_content}'")
510
+ if system is not None and system not in call.system_content:
511
+ mismatch.append(f"system content '{system}' not found in '{call.system_content}'")
512
+
513
+ if messages is not None:
514
+ if len(call.messages) != len(messages):
515
+ mismatch.append(
516
+ f"messages length: expected {len(messages)}, got {len(call.messages)}"
517
+ )
518
+ else:
519
+ for i, (actual, expected) in enumerate(zip(call.messages, messages)):
520
+ if isinstance(expected, Message):
521
+ if (
522
+ actual.role != expected.role
523
+ or actual.content != expected.content
524
+ ):
525
+ mismatch.append(
526
+ f"message[{i}]: expected {expected}, got {actual}"
527
+ )
528
+ elif (
529
+ isinstance(expected, dict)
530
+ and (
531
+ actual.role != expected.get("role")
532
+ or actual.content != expected.get("content")
533
+ )
534
+ ):
535
+ mismatch.append(
536
+ f"message[{i}]: expected {expected}, got {actual}"
537
+ )
538
+
539
+ if not mismatch:
540
+ matched = True
541
+ break
542
+ reasons.append(f"Call #{idx + 1}: " + "; ".join(mismatch))
543
+
544
+ if not matched:
545
+ details = "\n ".join(reasons)
546
+ raise AssertionError(
547
+ f"No AI call matched criteria.\nActual calls mismatch details:\n {details}"
548
+ )
549
+
550
+ def assert_embedding_called(self, times: int | None = None) -> None:
551
+ """Assert that an embedding call was intercepted."""
552
+ count = len(self._embedding_calls)
553
+ if times is None:
554
+ if count == 0:
555
+ raise AssertionError("Expected embedding to be called, but zero calls were made.")
556
+ elif count != times:
557
+ raise AssertionError(
558
+ f"Expected embedding to be called exactly {times} times, but was called {count} times."
559
+ )
560
+
561
+ def assert_embedding_called_once(self) -> None:
562
+ """Assert that exactly one embedding call was made."""
563
+ self.assert_embedding_called(times=1)
564
+
565
+ def assert_embedding_not_called(self) -> None:
566
+ """Assert that zero embedding calls were made."""
567
+ self.assert_embedding_called(times=0)
568
+
569
+ def assert_embedding_called_with(
570
+ self,
571
+ *,
572
+ text: str | None = None,
573
+ texts: Sequence[str] | None = None,
574
+ model: str | None = None,
575
+ provider: str | None = None,
576
+ ) -> None:
577
+ """Assert that at least one recorded embedding call matched criteria."""
578
+ if not self._embedding_calls:
579
+ raise AssertionError("Expected embedding call matching criteria, but zero calls were made.")
580
+
581
+ matched = False
582
+ reasons: list[str] = []
583
+
584
+ for idx, call in enumerate(self._embedding_calls):
585
+ mismatch = []
586
+ if text is not None and text not in call.texts:
587
+ mismatch.append(f"text '{text}' not found in call texts: {call.texts}")
588
+ if texts is not None and list(texts) != call.texts:
589
+ mismatch.append(f"texts: expected {list(texts)}, got {call.texts}")
590
+ if model is not None and call.model != model:
591
+ mismatch.append(f"model: expected '{model}', got '{call.model}'")
592
+ if provider is not None and call.provider != provider:
593
+ mismatch.append(f"provider: expected '{provider}', got '{call.provider}'")
594
+
595
+ if not mismatch:
596
+ matched = True
597
+ break
598
+ reasons.append(f"Embedding call #{idx + 1}: " + "; ".join(mismatch))
599
+
600
+ if not matched:
601
+ details = "\n ".join(reasons)
602
+ raise AssertionError(
603
+ f"No embedding call matched criteria.\nActual embedding details:\n {details}"
604
+ )
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from swiftpy.ai.agents.base_agent import (
4
+ Agent,
5
+ AgentResponse,
6
+ AgentTask,
7
+ AgentTracker,
8
+ BaseAgent,
9
+ )
10
+ from swiftpy.ai.agents.memory import (
11
+ AgentMemory,
12
+ BaseMemoryBackend,
13
+ DatabaseMemoryBackend,
14
+ InMemoryMemoryBackend,
15
+ RedisMemoryBackend,
16
+ )
17
+
18
+ __all__ = [
19
+ "Agent",
20
+ "AgentMemory",
21
+ "AgentResponse",
22
+ "AgentTask",
23
+ "AgentTracker",
24
+ "BaseAgent",
25
+ "BaseMemoryBackend",
26
+ "DatabaseMemoryBackend",
27
+ "InMemoryMemoryBackend",
28
+ "RedisMemoryBackend",
29
+ ]
30
+