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
@@ -0,0 +1,171 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import json
5
+ from collections.abc import Iterator, Sequence
6
+ from typing import Any, Self
7
+
8
+ from swiftpy.ai.providers.base import Message, ToolCall
9
+
10
+
11
+ def _clone_message(m: Message) -> Message:
12
+ """Create a deep clone of a Message preserving tool_calls and tool_call_id."""
13
+ cloned_tools: list[ToolCall] | None = None
14
+ if m.tool_calls:
15
+ cloned_tools = [
16
+ ToolCall(id=tc.id, name=tc.name, arguments=copy.deepcopy(tc.arguments))
17
+ for tc in m.tool_calls
18
+ ]
19
+ return Message(
20
+ role=m.role,
21
+ content=m.content,
22
+ name=m.name,
23
+ tool_calls=cloned_tools,
24
+ tool_call_id=m.tool_call_id,
25
+ )
26
+
27
+
28
+ def _deserialize_tool_call(raw_tc: Any) -> ToolCall | None:
29
+ """Deserialize an OpenAI-formatted or flat tool call dict into ToolCall."""
30
+ if not isinstance(raw_tc, dict):
31
+ return None
32
+ tc_id = str(raw_tc.get("id") or "")
33
+ fn = raw_tc.get("function")
34
+ if isinstance(fn, dict):
35
+ name = str(fn.get("name") or "")
36
+ raw_args = fn.get("arguments")
37
+ else:
38
+ name = str(raw_tc.get("name") or "")
39
+ raw_args = raw_tc.get("arguments")
40
+
41
+ if isinstance(raw_args, str):
42
+ try:
43
+ parsed = json.loads(raw_args)
44
+ args: dict[str, Any] = parsed if isinstance(parsed, dict) else {"raw": raw_args}
45
+ except (json.JSONDecodeError, TypeError):
46
+ args = {"raw": raw_args} if raw_args else {}
47
+ elif isinstance(raw_args, dict):
48
+ args = copy.deepcopy(raw_args)
49
+ else:
50
+ args = {}
51
+ return ToolCall(id=tc_id, name=name, arguments=args)
52
+
53
+
54
+ class Conversation:
55
+ """
56
+ Multi-turn conversation history container.
57
+
58
+ Manages ordered messages across dialogue turns with serialization
59
+ support for persistence and integration with AIClient.
60
+ """
61
+
62
+ def __init__(self, messages: Sequence[Message] | None = None) -> None:
63
+ self._messages: list[Message] = (
64
+ [_clone_message(m) for m in messages] if messages else []
65
+ )
66
+
67
+ def add_system(self, content: str) -> Self:
68
+ """Append a system prompt message."""
69
+ self._messages.append(Message(role="system", content=content))
70
+ return self
71
+
72
+ def add_user(self, content: str) -> Self:
73
+ """Append a user dialogue message."""
74
+ self._messages.append(Message(role="user", content=content))
75
+ return self
76
+
77
+ def add_assistant(self, content: str) -> Self:
78
+ """Append an assistant response message."""
79
+ self._messages.append(Message(role="assistant", content=content))
80
+ return self
81
+
82
+ def add_message(self, message: Message) -> Self:
83
+ """Append a pre-constructed Message instance."""
84
+ if not isinstance(message, Message):
85
+ raise TypeError(f"Expected Message instance, got {type(message).__name__}")
86
+ self._messages.append(message)
87
+ return self
88
+
89
+ @property
90
+ def messages(self) -> list[Message]:
91
+ """Return a defensive copy of the ordered messages list."""
92
+ return list(self._messages)
93
+
94
+ @property
95
+ def system_messages(self) -> list[Message]:
96
+ """Return all system prompt messages in order."""
97
+ return [m for m in self._messages if m.role == "system"]
98
+
99
+ @property
100
+ def dialogue_messages(self) -> list[Message]:
101
+ """Return non-system dialogue messages (user and assistant) in order."""
102
+ return [m for m in self._messages if m.role != "system"]
103
+
104
+ def clear(self) -> None:
105
+ """Remove all messages from the conversation."""
106
+ self._messages.clear()
107
+
108
+ def clone(self) -> Conversation:
109
+ """Create an independent deep copy of this conversation."""
110
+ return Conversation(messages=[_clone_message(m) for m in self._messages])
111
+
112
+ def to_dict_list(self) -> list[dict[str, Any]]:
113
+ """Serialize messages to a list of dictionaries."""
114
+ return [m.to_dict() for m in self._messages]
115
+
116
+ def to_json(self) -> str:
117
+ """Serialize conversation to JSON string."""
118
+ return json.dumps(self.to_dict_list())
119
+
120
+ @classmethod
121
+ def from_dict_list(cls, data: Sequence[dict[str, Any]]) -> Conversation:
122
+ """Construct a Conversation instance from a sequence of message dictionaries."""
123
+ messages: list[Message] = []
124
+ for item in data:
125
+ if not isinstance(item, dict):
126
+ raise TypeError(f"Expected dictionary for message, got {type(item).__name__}")
127
+ role = str(item.get("role", "user"))
128
+ content = str(item.get("content") or "")
129
+ name = item.get("name")
130
+ tool_call_id = item.get("tool_call_id")
131
+ raw_tool_calls = item.get("tool_calls")
132
+ tool_calls: list[ToolCall] | None = None
133
+ if isinstance(raw_tool_calls, list):
134
+ parsed_tcs = [
135
+ tc for raw_tc in raw_tool_calls if (tc := _deserialize_tool_call(raw_tc)) is not None
136
+ ]
137
+ if parsed_tcs:
138
+ tool_calls = parsed_tcs
139
+ messages.append(
140
+ Message(
141
+ role=role,
142
+ content=content,
143
+ name=str(name) if name else None,
144
+ tool_calls=tool_calls,
145
+ tool_call_id=str(tool_call_id) if tool_call_id else None,
146
+ )
147
+ )
148
+ return cls(messages=messages)
149
+
150
+ @classmethod
151
+ def from_json(cls, json_str: str) -> Conversation:
152
+ """Deserialize a Conversation instance from a JSON string."""
153
+ if not json_str or not json_str.strip():
154
+ return cls()
155
+ try:
156
+ data = json.loads(json_str)
157
+ except (json.JSONDecodeError, ValueError) as exc:
158
+ raise ValueError(f"Invalid JSON string for conversation: {exc}") from exc
159
+
160
+ if not isinstance(data, list):
161
+ raise TypeError(f"Expected a JSON list of messages, got {type(data).__name__}")
162
+ return cls.from_dict_list(data)
163
+
164
+ def __len__(self) -> int:
165
+ return len(self._messages)
166
+
167
+ def __iter__(self) -> Iterator[Message]:
168
+ return iter(self._messages)
169
+
170
+ def __getitem__(self, index: int) -> Message:
171
+ return self._messages[index]
@@ -0,0 +1,321 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import math
5
+ from collections.abc import Sequence
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ _HTTPX_ERRORS: tuple[type[Exception], ...] = (httpx.TimeoutException, httpx.RequestError)
11
+
12
+ from swiftpy.ai._deterministic import generate_deterministic_embedding
13
+ from swiftpy.ai._fake import AIFake
14
+ from swiftpy.ai.exceptions import (
15
+ AIAuthenticationError,
16
+ AIConfigurationError,
17
+ AIConnectionError,
18
+ AIModuleDisabledError,
19
+ AIProviderError,
20
+ AIRateLimitError,
21
+ )
22
+ from swiftpy.ai.pool import AIHttpPool
23
+ from swiftpy.ai.providers.base import redact_sensitive_text
24
+ from swiftpy.core.config import Config
25
+
26
+ PROVIDER_MAX_BATCH_SIZE: dict[str, int] = {
27
+ "openai": 2048,
28
+ }
29
+
30
+
31
+ class EmbeddingsClient:
32
+ """Client for generating text embeddings with batch chunking and concurrency."""
33
+
34
+ def __init__(
35
+ self,
36
+ config: Config | None = None,
37
+ http_pool: AIHttpPool | None = None,
38
+ ) -> None:
39
+ if config is None:
40
+ try:
41
+ self._config = Config.get_instance()
42
+ except RuntimeError:
43
+ self._config = Config()
44
+ else:
45
+ self._config = config
46
+
47
+ self._http_pool = http_pool or AIHttpPool.get_instance()
48
+
49
+ def _resolve_provider_and_key(self, provider_override: str | None) -> tuple[str, str, str, str]:
50
+ if not self._config.get("ai.enabled", True):
51
+ raise AIModuleDisabledError("AI module is disabled in configuration.")
52
+
53
+ provider = (provider_override or str(self._config.get("ai.default", "openai"))).lower()
54
+ if provider != "openai" and provider != "deterministic":
55
+ raise AIConfigurationError(
56
+ f"Embedding provider '{provider}' is not supported. Use 'openai' or 'deterministic'."
57
+ )
58
+
59
+ raw_cfg = self._config.get(f"ai.providers.{provider}", {})
60
+ prov_cfg: dict[str, Any] = raw_cfg if isinstance(raw_cfg, dict) else {}
61
+
62
+ api_key = str(prov_cfg.get("api_key", "")).strip()
63
+ base_url = str(prov_cfg.get("base_url", "https://api.openai.com/v1")).rstrip("/")
64
+ default_model = str(prov_cfg.get("embedding_model", "text-embedding-3-small"))
65
+
66
+ return provider, api_key, base_url, default_model
67
+
68
+ def _resolve_timeout(self, provider: str) -> float:
69
+ raw_cfg = self._config.get(f"ai.providers.{provider}", {})
70
+ if isinstance(raw_cfg, dict) and raw_cfg.get("timeout") is not None:
71
+ return float(raw_cfg["timeout"])
72
+ return float(self._http_pool.timeout)
73
+
74
+ async def _embed_chunk_openai(
75
+ self,
76
+ texts: list[str],
77
+ model: str,
78
+ base_url: str,
79
+ api_key: str,
80
+ timeout: float | None = None,
81
+ ) -> list[list[float]]:
82
+ if not api_key:
83
+ raise AIConfigurationError(
84
+ "Missing API key for embedding provider 'openai'. "
85
+ "Please configure 'ai.providers.openai.api_key' or set OPENAI_API_KEY."
86
+ )
87
+
88
+ url = f"{base_url}/embeddings"
89
+ headers = {
90
+ "Authorization": f"Bearer {api_key}",
91
+ "Content-Type": "application/json",
92
+ }
93
+ payload = {
94
+ "model": model,
95
+ "input": texts,
96
+ }
97
+
98
+ effective_timeout = self._resolve_timeout("openai") if timeout is None else timeout
99
+ client = self._http_pool.get_client()
100
+ try:
101
+ response = await client.post(url, headers=headers, json=payload, timeout=effective_timeout)
102
+ except _HTTPX_ERRORS as exc:
103
+ safe_exc = redact_sensitive_text(str(exc), api_key=api_key)
104
+ raise AIConnectionError(
105
+ f"Failed to connect to embedding endpoint: {safe_exc}",
106
+ provider="openai",
107
+ ) from exc
108
+
109
+ safe_body = redact_sensitive_text(response.text, api_key=api_key)
110
+ if response.status_code in (401, 403):
111
+ raise AIAuthenticationError(
112
+ f"OpenAI embedding authentication failed: {safe_body}",
113
+ provider="openai",
114
+ status_code=response.status_code,
115
+ response_body=safe_body,
116
+ )
117
+ if response.status_code == 429:
118
+ raise AIRateLimitError(
119
+ f"OpenAI embedding rate limit exceeded: {safe_body}",
120
+ provider="openai",
121
+ status_code=response.status_code,
122
+ response_body=safe_body,
123
+ )
124
+ if response.status_code >= 400:
125
+ raise AIProviderError(
126
+ f"OpenAI embedding error ({response.status_code}): {safe_body}",
127
+ provider="openai",
128
+ status_code=response.status_code,
129
+ response_body=safe_body,
130
+ )
131
+
132
+ try:
133
+ data = response.json()
134
+ except Exception as exc:
135
+ raise AIProviderError("Embedding response is not valid JSON", provider="openai") from exc
136
+
137
+ if not isinstance(data, dict):
138
+ raise AIProviderError("Embedding response payload is not a JSON object", provider="openai")
139
+
140
+ items = data.get("data")
141
+ if not isinstance(items, list):
142
+ raise AIProviderError("Embedding response missing 'data' list", provider="openai")
143
+
144
+ if len(items) != len(texts):
145
+ raise AIProviderError(
146
+ f"Embedding response items count ({len(items)}) does not match request chunk count ({len(texts)})",
147
+ provider="openai",
148
+ )
149
+
150
+ seen_indices: set[int] = set()
151
+ vectors_by_index: dict[int, list[float]] = {}
152
+ expected_dim: int | None = None
153
+
154
+ for pos, item in enumerate(items):
155
+ if not isinstance(item, dict):
156
+ raise AIProviderError(f"Embedding item at position {pos} is not a dictionary", provider="openai")
157
+
158
+ if "index" not in item:
159
+ raise AIProviderError(f"Embedding item at position {pos} is missing required 'index' field", provider="openai")
160
+
161
+ raw_idx = item["index"]
162
+ if not isinstance(raw_idx, int) or isinstance(raw_idx, bool):
163
+ raise AIProviderError(
164
+ f"Embedding item 'index' must be an integer, got {type(raw_idx).__name__}",
165
+ provider="openai",
166
+ )
167
+
168
+ if raw_idx < 0 or raw_idx >= len(texts):
169
+ raise AIProviderError(
170
+ f"Embedding item index {raw_idx} is out of bounds for chunk of size {len(texts)}",
171
+ provider="openai",
172
+ )
173
+
174
+ if raw_idx in seen_indices:
175
+ raise AIProviderError(f"Duplicate embedding index {raw_idx} received from provider", provider="openai")
176
+ seen_indices.add(raw_idx)
177
+
178
+ if "embedding" not in item:
179
+ raise AIProviderError(f"Embedding item at index {raw_idx} is missing 'embedding' vector", provider="openai")
180
+
181
+ raw_vec = item["embedding"]
182
+ if not isinstance(raw_vec, list) or not raw_vec:
183
+ raise AIProviderError(f"Embedding item at index {raw_idx} has invalid or empty vector", provider="openai")
184
+
185
+ if expected_dim is None:
186
+ expected_dim = len(raw_vec)
187
+ elif len(raw_vec) != expected_dim:
188
+ raise AIProviderError(
189
+ f"Inconsistent vector dimensions: expected {expected_dim}, got {len(raw_vec)} at index {raw_idx}",
190
+ provider="openai",
191
+ )
192
+
193
+ parsed_vec: list[float] = []
194
+ for val in raw_vec:
195
+ if isinstance(val, (int, float)) and not isinstance(val, bool):
196
+ fval = float(val)
197
+ if math.isnan(fval) or math.isinf(fval):
198
+ raise AIProviderError(
199
+ f"Embedding vector at index {raw_idx} contains NaN or infinite value",
200
+ provider="openai",
201
+ )
202
+ parsed_vec.append(fval)
203
+ else:
204
+ raise AIProviderError(
205
+ f"Non-numeric value in embedding vector at index {raw_idx}",
206
+ provider="openai",
207
+ )
208
+
209
+ vectors_by_index[raw_idx] = parsed_vec
210
+
211
+ # Guarantee preservation of original document order
212
+ return [vectors_by_index[i] for i in range(len(texts))]
213
+
214
+ async def embed(
215
+ self,
216
+ text: str,
217
+ model: str | None = None,
218
+ provider: str | None = None,
219
+ ) -> list[float]:
220
+ """Generate an embedding vector for a single text."""
221
+ if not isinstance(text, str):
222
+ raise TypeError(f"Expected string for embedding, got {type(text).__name__}")
223
+ if not text.strip():
224
+ raise ValueError("Cannot embed empty or whitespace-only text.")
225
+
226
+ results = await self.embed_batch([text], model=model, provider=provider, chunk_size=1)
227
+ return results[0]
228
+
229
+ async def embed_batch(
230
+ self,
231
+ texts: Sequence[str],
232
+ model: str | None = None,
233
+ provider: str | None = None,
234
+ chunk_size: int = 100,
235
+ concurrency: int = 5,
236
+ ) -> list[list[float]]:
237
+ """
238
+ Embed a collection of texts, automatically chunking large lists into
239
+ provider-safe batches and executing chunks concurrently with bounded parallelism.
240
+ """
241
+ if not texts:
242
+ return []
243
+ if chunk_size <= 0:
244
+ raise ValueError(f"chunk_size must be a positive integer, got {chunk_size}")
245
+ if concurrency <= 0:
246
+ raise ValueError(f"concurrency must be a positive integer, got {concurrency}")
247
+
248
+ for idx, t in enumerate(texts):
249
+ if not isinstance(t, str):
250
+ raise TypeError(f"Item at index {idx} must be a string, got {type(t).__name__}")
251
+ if not t.strip():
252
+ raise ValueError(f"Item at index {idx} is empty or whitespace only.")
253
+
254
+ active_fake = AIFake.get_active()
255
+ if active_fake is not None:
256
+ return await active_fake.handle_embed(
257
+ texts,
258
+ model=model,
259
+ provider=provider,
260
+ )
261
+
262
+ prov_name, api_key, base_url, default_model = self._resolve_provider_and_key(provider)
263
+ target_model = model or default_model
264
+
265
+ if prov_name == "deterministic":
266
+ return [generate_deterministic_embedding(t) for t in texts]
267
+ if not api_key:
268
+ raise AIConfigurationError(
269
+ f"Missing API key for embedding provider '{prov_name}'. "
270
+ f"Please configure 'ai.providers.{prov_name}.api_key' or set OPENAI_API_KEY."
271
+ )
272
+
273
+ effective_timeout = self._resolve_timeout(prov_name)
274
+
275
+ # Respect provider-specific maximum batch size limits
276
+ max_allowed = PROVIDER_MAX_BATCH_SIZE.get(prov_name, chunk_size)
277
+ effective_chunk_size = min(chunk_size, max_allowed)
278
+
279
+ # Chunk the input texts into slices of effective_chunk_size
280
+ text_list = list(texts)
281
+ chunks: list[list[str]] = [
282
+ text_list[i : i + effective_chunk_size]
283
+ for i in range(0, len(text_list), effective_chunk_size)
284
+ ]
285
+
286
+ semaphore = asyncio.Semaphore(concurrency)
287
+
288
+ async def _process_chunk(chunk: list[str]) -> list[list[float]]:
289
+ async with semaphore:
290
+ return await self._embed_chunk_openai(
291
+ chunk,
292
+ model=target_model,
293
+ base_url=base_url,
294
+ api_key=api_key,
295
+ timeout=effective_timeout,
296
+ )
297
+
298
+ # Create tasks and handle cancellation on failure
299
+ tasks = [asyncio.create_task(_process_chunk(c)) for c in chunks]
300
+ try:
301
+ chunk_results = await asyncio.gather(*tasks)
302
+ except Exception:
303
+ for task in tasks:
304
+ if not task.done():
305
+ task.cancel()
306
+ await asyncio.gather(*tasks, return_exceptions=True)
307
+ raise
308
+
309
+ # Flatten in original order
310
+ embeddings: list[list[float]] = []
311
+ for res in chunk_results:
312
+ embeddings.extend(res)
313
+
314
+ return embeddings
315
+
316
+
317
+ __all__ = [
318
+ "PROVIDER_MAX_BATCH_SIZE",
319
+ "EmbeddingsClient",
320
+ "generate_deterministic_embedding",
321
+ ]
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class AIError(Exception):
5
+ """Base exception for all SwiftPY AI module errors."""
6
+
7
+
8
+ class AIModuleDisabledError(AIError):
9
+ """Raised when an AI operation is attempted but the AI module is disabled in configuration."""
10
+
11
+
12
+ class AIConfigurationError(AIError):
13
+ """Raised when AI configuration is missing, incomplete, or invalid."""
14
+
15
+
16
+ class AIProviderError(AIError):
17
+ """Raised when an LLM provider encounters an error during request execution."""
18
+
19
+ def __init__(
20
+ self,
21
+ message: str,
22
+ provider: str = "",
23
+ status_code: int | None = None,
24
+ response_body: str | None = None,
25
+ ) -> None:
26
+ super().__init__(message)
27
+ self.provider = provider
28
+ self.status_code = status_code
29
+ self.response_body = response_body
30
+
31
+
32
+ class AIAuthenticationError(AIProviderError):
33
+ """Raised when provider authentication fails (e.g. invalid or missing API key)."""
34
+
35
+
36
+ class AIRateLimitError(AIProviderError):
37
+ """Raised when a provider rejects a request due to rate or quota limits."""
38
+
39
+ def __init__(
40
+ self,
41
+ message: str,
42
+ provider: str = "",
43
+ status_code: int | None = 429,
44
+ response_body: str | None = None,
45
+ retry_after: float | None = None,
46
+ ) -> None:
47
+ super().__init__(
48
+ message=message,
49
+ provider=provider,
50
+ status_code=status_code,
51
+ response_body=response_body,
52
+ )
53
+ self.retry_after = retry_after
54
+
55
+
56
+ class AIConnectionError(AIProviderError):
57
+ """Raised when an HTTP connection or network timeout error occurs while contacting a provider."""
58
+
59
+
60
+ class AgentLoopError(AIError):
61
+ """Raised when an autonomous agent or tool execution loop exceeds configured bounds (e.g. max_iterations)."""
62
+
63
+ def __init__(self, message: str, iterations: int = 0) -> None:
64
+ super().__init__(message)
65
+ self.iterations = iterations
66
+
67
+
68
+ class AIBudgetExceededError(AIError):
69
+ """Raised when an operation exceeds the configured daily token or cost budget."""
70
+
71
+
72
+ class AIContentModerationError(AIError):
73
+ """Raised when content fails moderation policy checks."""
74
+
75
+