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,542 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import ipaddress
5
+ import math
6
+ import re
7
+ from abc import ABC, abstractmethod
8
+ from collections.abc import Sequence
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, cast
11
+ from urllib.parse import quote, urlparse
12
+
13
+ from swiftpy.ai.exceptions import (
14
+ AIAuthenticationError,
15
+ AIModuleDisabledError,
16
+ AIProviderError,
17
+ AIRateLimitError,
18
+ )
19
+ from swiftpy.ai.pool import AIHttpPool
20
+ from swiftpy.core.config import Config
21
+ from swiftpy.core.container import Container, get_container
22
+
23
+ ALLOWED_METRICS: frozenset[str] = frozenset({"cosine", "l2", "inner_product"})
24
+ ALLOWED_FILTER_OPERATORS: frozenset[str] = frozenset(
25
+ {"$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$in", "$nin", "$and", "$or", "$exists"}
26
+ )
27
+ RESERVED_META_KEYS: frozenset[str] = frozenset({"_content", "_orig_id"})
28
+
29
+ _COLLECTION_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$")
30
+ _PINECONE_ENV_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
31
+ _PINECONE_HOST_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9.-]*[A-Za-z0-9]$")
32
+
33
+
34
+ def validate_collection_name(name: str) -> str:
35
+ """Validate collection/index names to block path traversal, query injection, and host injection."""
36
+ if not isinstance(name, str) or not _COLLECTION_NAME_RE.fullmatch(name):
37
+ raise ValueError(
38
+ f"Invalid collection name {name!r}. "
39
+ "Collection names must start with an alphanumeric character and contain only [A-Za-z0-9_-] (1-128 chars)."
40
+ )
41
+ return name
42
+
43
+
44
+ def validate_record_id(record_id: str) -> str:
45
+ """Validate a record identifier is a non-empty string free of null bytes or ASCII control chars."""
46
+ if not isinstance(record_id, str) or not record_id or not record_id.strip():
47
+ raise ValueError("Record id must be a non-empty string.")
48
+ if "\x00" in record_id or any(ord(ch) < 32 for ch in record_id):
49
+ raise ValueError("Record id must not contain control characters.")
50
+ return record_id
51
+
52
+
53
+ def encode_path_segment(value: str) -> str:
54
+ """Percent-encode a path segment for safe HTTP URL interpolation."""
55
+ return quote(value, safe="")
56
+
57
+
58
+ def dedupe_preserve_order(ids: Sequence[str]) -> list[str]:
59
+ """Deduplicate non-empty string IDs while preserving caller order."""
60
+ return list(dict.fromkeys(ids))
61
+
62
+
63
+ def validate_pinecone_environment(environment: str) -> str:
64
+ """Validate Pinecone environment identifier against strict DNS-label characters."""
65
+ if not isinstance(environment, str) or not _PINECONE_ENV_RE.fullmatch(environment):
66
+ raise ValueError(
67
+ f"Invalid Pinecone environment {environment!r}. Expected alphanumeric/hyphen/underscore token."
68
+ )
69
+ return environment
70
+
71
+
72
+ def validate_pinecone_url(url: str, *, require_pinecone_host: bool = False) -> str:
73
+ """Validate a Pinecone endpoint URL, rejecting credentials, fragments, queries, and SSRF targets."""
74
+ if not isinstance(url, str) or not url.strip():
75
+ raise ValueError("Pinecone URL must be a non-empty string.")
76
+ trimmed = url.strip().rstrip("/")
77
+ if any(ch in trimmed for ch in ("#", "?", "@")) or any(ch.isspace() for ch in trimmed):
78
+ raise ValueError(f"Invalid Pinecone URL {url!r}: contains disallowed characters.")
79
+ parsed = urlparse(trimmed)
80
+ if parsed.scheme not in ("https", "http"):
81
+ raise ValueError(f"Invalid Pinecone URL scheme in {url!r}.")
82
+ if require_pinecone_host and parsed.scheme != "https":
83
+ raise ValueError(f"Pinecone data-plane host URL must use https, got {url!r}.")
84
+ if parsed.username or parsed.password or parsed.params or parsed.query or parsed.fragment:
85
+ raise ValueError(f"Invalid Pinecone URL components in {url!r}.")
86
+ host = parsed.hostname
87
+ if not host or not _PINECONE_HOST_RE.fullmatch(host):
88
+ raise ValueError(f"Invalid Pinecone hostname in {url!r}.")
89
+ # Block link-local / raw metadata IPs
90
+ try:
91
+ ip_obj = ipaddress.ip_address(host)
92
+ if ip_obj.is_link_local:
93
+ raise ValueError(f"Disallowed link-local Pinecone IP host in {url!r}.")
94
+ except ValueError as ip_err:
95
+ if "link-local" in str(ip_err):
96
+ raise
97
+ if require_pinecone_host and not host.endswith(".pinecone.io"):
98
+ raise ValueError(f"Untrusted Pinecone host {host!r}: expected *.pinecone.io suffix.")
99
+ return trimmed
100
+
101
+
102
+ def validate_filter_operators(
103
+ flt: dict[str, Any],
104
+ *,
105
+ allowed_ops: frozenset[str] = ALLOWED_FILTER_OPERATORS,
106
+ nested_allowed: bool = False,
107
+ ) -> None:
108
+ """Validate metadata filter dictionary keys and dollar-prefixed query operators."""
109
+ if not isinstance(flt, dict):
110
+ raise TypeError(f"Filter must be a dict, got {type(flt).__name__}.")
111
+ for raw_key, value in flt.items():
112
+ if not isinstance(raw_key, str) or not raw_key or "\x00" in raw_key:
113
+ raise ValueError(f"Invalid filter key {raw_key!r}.")
114
+ if raw_key.startswith("$"):
115
+ if raw_key not in allowed_ops:
116
+ raise ValueError(f"Unsupported filter operator {raw_key!r}.")
117
+ if raw_key in ("$and", "$or"):
118
+ if not isinstance(value, Sequence) or isinstance(value, (str, bytes, dict)):
119
+ raise ValueError(f"Operator {raw_key!r} expects a sequence of filter clauses.")
120
+ for clause in value:
121
+ validate_filter_operators(clause, allowed_ops=allowed_ops, nested_allowed=nested_allowed)
122
+ elif raw_key in ("$in", "$nin"):
123
+ if not isinstance(value, Sequence) or isinstance(value, (str, bytes, dict)):
124
+ raise ValueError(f"Operator {raw_key!r} expects a sequence of scalar values.")
125
+ elif raw_key == "$exists" and not isinstance(value, bool):
126
+ raise ValueError("Operator '$exists' expects a boolean.")
127
+ continue
128
+
129
+ if isinstance(value, dict):
130
+ if any(isinstance(k, str) and k.startswith("$") for k in value):
131
+ for op_key, op_val in value.items():
132
+ if not isinstance(op_key, str) or not op_key.startswith("$") or op_key not in allowed_ops:
133
+ raise ValueError(f"Unsupported filter operator {op_key!r} on field {raw_key!r}.")
134
+ if op_key in ("$in", "$nin") and (
135
+ not isinstance(op_val, Sequence) or isinstance(op_val, (str, bytes, dict))
136
+ ):
137
+ raise ValueError(f"Operator {op_key!r} expects a sequence of values.")
138
+ elif not nested_allowed:
139
+ raise ValueError(
140
+ f"Nested dictionary filter on key {raw_key!r} is not supported by this adapter."
141
+ )
142
+ else:
143
+ validate_filter_operators(value, allowed_ops=allowed_ops, nested_allowed=True)
144
+
145
+
146
+ @dataclass(frozen=True, slots=True)
147
+ class VectorStoreCapabilities:
148
+ """Declared capabilities supported by a vector store adapter."""
149
+
150
+ metadata_filtering: bool
151
+ nested_metadata_filtering: bool
152
+ namespaces: bool
153
+ pagination: bool
154
+ batch_upsert: bool
155
+ native_distance_metrics: tuple[str, ...]
156
+
157
+
158
+ @dataclass(slots=True)
159
+ class SearchResult:
160
+ """
161
+ Represents a vector similarity search result.
162
+
163
+ Public Scoring Contract:
164
+ -----------------------
165
+ In SwiftPY, `SearchResult.score` is normalized such that higher values
166
+ consistently indicate greater similarity across all adapters:
167
+ - 'cosine': Cosine similarity in range [-1.0, 1.0], where 1.0 is identical and -1.0 is opposite.
168
+ - 'l2': Negated Euclidean distance (-||a - b||_2) in range (-inf, 0.0], where 0.0 is identical.
169
+ - 'inner_product': Dot product sum (sum(a_i * b_i)).
170
+ """
171
+
172
+ id: str
173
+ score: float
174
+ metadata: dict[str, Any] = field(default_factory=dict)
175
+ content: str = ""
176
+ vector: list[float] | None = None
177
+
178
+
179
+ @dataclass(slots=True)
180
+ class VectorRecord:
181
+ """Represents an individual vector record for insertion or update."""
182
+
183
+ id: str
184
+ vector: list[float]
185
+ metadata: dict[str, Any] = field(default_factory=dict)
186
+ content: str = ""
187
+
188
+
189
+ def validate_vector(
190
+ vector: Sequence[float],
191
+ dimensions: int | None = None,
192
+ metric: str = "cosine",
193
+ *,
194
+ zero_norm_message: str | None = None,
195
+ ) -> list[float]:
196
+ """
197
+ Validate that vector is a non-empty sequence of finite floats matching dimensions.
198
+ For cosine distance, strictly rejects zero-magnitude vectors.
199
+ """
200
+ if not isinstance(vector, (list, tuple)) or not vector:
201
+ raise ValueError("Vector must be a non-empty sequence of floats.")
202
+ if dimensions is not None and len(vector) != dimensions:
203
+ raise ValueError(
204
+ f"Vector dimension {len(vector)} does not match collection dimension {dimensions}"
205
+ )
206
+
207
+ parsed: list[float] = []
208
+ norm_sq = 0.0
209
+ for idx, x in enumerate(vector):
210
+ if not isinstance(x, (int, float)) or isinstance(x, bool):
211
+ raise TypeError(
212
+ f"Vector element at index {idx} must be a float, got {type(x).__name__}"
213
+ )
214
+ fx = float(x)
215
+ if math.isnan(fx) or math.isinf(fx):
216
+ raise ValueError(f"Vector element at index {idx} is NaN or Infinite.")
217
+ parsed.append(fx)
218
+ norm_sq += fx * fx
219
+
220
+ if metric == "cosine" and norm_sq <= 1e-12:
221
+ raise ValueError(
222
+ zero_norm_message or "Cannot calculate cosine similarity for a zero-magnitude vector."
223
+ )
224
+
225
+ return parsed
226
+
227
+
228
+ def normalize_record(
229
+ record: VectorRecord | dict[str, Any] | tuple[Any, ...],
230
+ dimensions: int | None = None,
231
+ metric: str = "cosine",
232
+ ) -> VectorRecord:
233
+ """Normalize and validate a vector record input format."""
234
+ if isinstance(record, VectorRecord):
235
+ rec_id = record.id
236
+ rec_vec = record.vector
237
+ rec_meta = copy.deepcopy(dict(record.metadata)) if record.metadata else {}
238
+ rec_content = record.content
239
+ elif isinstance(record, dict):
240
+ if "id" not in record or "vector" not in record:
241
+ raise ValueError("Record dict must contain 'id' and 'vector' keys.")
242
+ rec_id = str(record["id"])
243
+ rec_vec = record["vector"]
244
+ raw_meta = record.get("metadata")
245
+ rec_meta = copy.deepcopy(dict(raw_meta)) if raw_meta else {}
246
+ rec_content = str(record.get("content", ""))
247
+ elif isinstance(record, (list, tuple)) and len(record) >= 2:
248
+ rec_id = str(record[0])
249
+ rec_vec = record[1]
250
+ rec_meta = copy.deepcopy(dict(record[2])) if len(record) > 2 and record[2] is not None else {}
251
+ rec_content = str(record[3]) if len(record) > 3 and record[3] is not None else ""
252
+ else:
253
+ raise TypeError(f"Unsupported record format: {type(record).__name__}")
254
+
255
+ rec_id = validate_record_id(rec_id)
256
+
257
+ parsed_vec = validate_vector(rec_vec, dimensions, metric)
258
+ return VectorRecord(
259
+ id=rec_id,
260
+ vector=parsed_vec,
261
+ metadata=rec_meta,
262
+ content=rec_content,
263
+ )
264
+
265
+
266
+ _REDACT_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
267
+ (re.compile(r"(Bearer\s+)[^\s\"',]+", re.IGNORECASE), r"\1[REDACTED]"),
268
+ (re.compile(r"((?:api[-_]?key|x[-_]api[-_]?key)[\"'\s:=]+)[^\s\"',]+", re.IGNORECASE), r"\1[REDACTED]"),
269
+ (re.compile(r"(token[\"'\s:=]+)[^\s\"',]+", re.IGNORECASE), r"\1[REDACTED]"),
270
+ (re.compile(r"(password[\"'\s:=]+)[^\s\"',]+", re.IGNORECASE), r"\1[REDACTED]"),
271
+ (re.compile(r"sk-[A-Za-z0-9_\-]{8,}"), "[REDACTED]"),
272
+ (re.compile(r"pcsk_[A-Za-z0-9_\-]{8,}"), "[REDACTED]"),
273
+ )
274
+
275
+
276
+ def sanitize_error_message(text: str) -> str:
277
+ """Sanitize sensitive credentials, API keys, and tokens from error messages."""
278
+ if not text:
279
+ return ""
280
+ sanitized = text
281
+ for pattern, repl in _REDACT_PATTERNS:
282
+ sanitized = pattern.sub(repl, sanitized)
283
+ return sanitized
284
+
285
+
286
+ def handle_adapter_http_error(status_code: int, text: str, provider: str) -> None:
287
+ """Translate raw HTTP error status codes into SwiftPY AI exceptions."""
288
+ clean_text = sanitize_error_message(text)
289
+ if status_code in (401, 403):
290
+ raise AIAuthenticationError(
291
+ f"[{provider}] Authentication failed (HTTP {status_code}): {clean_text}"
292
+ )
293
+ if status_code == 429:
294
+ raise AIRateLimitError(
295
+ f"[{provider}] Rate limit exceeded (HTTP 429): {clean_text}"
296
+ )
297
+ if status_code == 400:
298
+ raise AIProviderError(
299
+ f"[{provider}] Bad request (HTTP 400): {clean_text}"
300
+ )
301
+ if status_code == 404:
302
+ raise AIProviderError(
303
+ f"[{provider}] Resource not found (HTTP 404): {clean_text}"
304
+ )
305
+ if status_code == 409:
306
+ raise AIProviderError(
307
+ f"[{provider}] Conflict error (HTTP 409): {clean_text}"
308
+ )
309
+ if status_code == 413:
310
+ raise AIProviderError(
311
+ f"[{provider}] Payload too large (HTTP 413): {clean_text}"
312
+ )
313
+ if status_code == 422:
314
+ raise AIProviderError(
315
+ f"[{provider}] Unprocessable entity (HTTP 422): {clean_text}"
316
+ )
317
+ if status_code == 503:
318
+ raise AIProviderError(
319
+ f"[{provider}] Service unavailable (HTTP 503): {clean_text}"
320
+ )
321
+ if status_code >= 400:
322
+ raise AIProviderError(
323
+ f"[{provider}] API error (HTTP {status_code}): {clean_text}"
324
+ )
325
+
326
+
327
+ _ADAPTER_REGISTRY: dict[str, type[VectorStore]] = {}
328
+
329
+
330
+ class VectorStore(ABC):
331
+ """
332
+ Abstract contract and unified facade for vector database stores.
333
+ """
334
+
335
+ @classmethod
336
+ def register_adapter(cls, name: str, adapter_cls: type[VectorStore]) -> None:
337
+ """Register a vector store adapter implementation."""
338
+ _ADAPTER_REGISTRY[name.lower().strip()] = adapter_cls
339
+
340
+ @classmethod
341
+ def get_adapter_cls(cls, name: str) -> type[VectorStore] | None:
342
+ """Retrieve a registered adapter class by name."""
343
+ return _ADAPTER_REGISTRY.get(name.lower().strip())
344
+
345
+ @classmethod
346
+ def _create_from_di(
347
+ cls,
348
+ adapter_name: str,
349
+ collection: str = "default",
350
+ dimensions: int = 1536,
351
+ metric: str = "cosine",
352
+ **kwargs: Any,
353
+ ) -> VectorStore:
354
+ """
355
+ Instantiate and configure an adapter using the DI container and application configuration.
356
+ """
357
+ try:
358
+ container = get_container()
359
+ except RuntimeError:
360
+ container = Container()
361
+
362
+ adapter_cls = cls.get_adapter_cls(adapter_name)
363
+ if adapter_cls is None:
364
+ raise ValueError(
365
+ f"Unknown vector store adapter '{adapter_name}'. "
366
+ f"Available adapters: {sorted(_ADAPTER_REGISTRY.keys())}"
367
+ )
368
+
369
+ has_explicit_override = (
370
+ collection != "default"
371
+ or dimensions != 1536
372
+ or metric != "cosine"
373
+ or bool(kwargs)
374
+ )
375
+ if container.has(adapter_cls) and not has_explicit_override:
376
+ instance = container.resolve(adapter_cls)
377
+ if isinstance(instance, VectorStore):
378
+ return instance
379
+
380
+ config = container.resolve(Config) if container.can_resolve(Config) else Config()
381
+ if not config.get("ai.enabled", True):
382
+ raise AIModuleDisabledError("AI module is disabled in configuration (ai.enabled is false).")
383
+
384
+ factory: Any = adapter_cls
385
+ if adapter_name == "in_memory":
386
+ return cast(
387
+ VectorStore,
388
+ factory(
389
+ collection_name=collection,
390
+ dimensions=dimensions,
391
+ metric=metric,
392
+ **kwargs,
393
+ ),
394
+ )
395
+
396
+ http_pool: AIHttpPool | None = kwargs.pop("http_pool", None)
397
+ if http_pool is None:
398
+ if container.has(AIHttpPool):
399
+ http_pool = container.resolve(AIHttpPool)
400
+ else:
401
+ http_pool = AIHttpPool.get_instance()
402
+
403
+ cfg_section = config.get(f"ai.vectorstores.{adapter_name}", {})
404
+ merged_kwargs: dict[str, Any] = dict(cfg_section) if isinstance(cfg_section, dict) else {}
405
+ merged_kwargs.update(kwargs)
406
+ merged_kwargs["collection_name"] = collection
407
+ merged_kwargs["dimensions"] = dimensions
408
+ merged_kwargs["metric"] = metric
409
+ merged_kwargs["config"] = config
410
+ merged_kwargs["http_pool"] = http_pool
411
+
412
+ return cast(VectorStore, factory(**merged_kwargs))
413
+
414
+ @classmethod
415
+ def qdrant(
416
+ cls,
417
+ collection: str = "default",
418
+ dimensions: int = 1536,
419
+ metric: str = "cosine",
420
+ **kwargs: Any,
421
+ ) -> VectorStore:
422
+ """Return a configured Qdrant vector store adapter from DI container."""
423
+ return cls._create_from_di("qdrant", collection, dimensions, metric, **kwargs)
424
+
425
+ @classmethod
426
+ def lancedb(
427
+ cls,
428
+ collection: str = "default",
429
+ dimensions: int = 1536,
430
+ metric: str = "cosine",
431
+ **kwargs: Any,
432
+ ) -> VectorStore:
433
+ """Return a configured LanceDB vector store adapter from DI container."""
434
+ return cls._create_from_di("lancedb", collection, dimensions, metric, **kwargs)
435
+
436
+ @classmethod
437
+ def chroma(
438
+ cls,
439
+ collection: str = "default",
440
+ dimensions: int = 1536,
441
+ metric: str = "cosine",
442
+ **kwargs: Any,
443
+ ) -> VectorStore:
444
+ """Return a configured Chroma vector store adapter from DI container."""
445
+ return cls._create_from_di("chroma", collection, dimensions, metric, **kwargs)
446
+
447
+ @classmethod
448
+ def pinecone(
449
+ cls,
450
+ collection: str = "default",
451
+ dimensions: int = 1536,
452
+ metric: str = "cosine",
453
+ **kwargs: Any,
454
+ ) -> VectorStore:
455
+ """Return a configured Pinecone vector store adapter from DI container."""
456
+ return cls._create_from_di("pinecone", collection, dimensions, metric, **kwargs)
457
+
458
+ @classmethod
459
+ def in_memory(
460
+ cls,
461
+ collection: str = "default",
462
+ dimensions: int = 1536,
463
+ metric: str = "cosine",
464
+ **kwargs: Any,
465
+ ) -> VectorStore:
466
+ """Return a configured InMemory vector store adapter from DI container."""
467
+ return cls._create_from_di("in_memory", collection, dimensions, metric, **kwargs)
468
+
469
+ @classmethod
470
+ def pgvector(
471
+ cls,
472
+ collection: str = "default",
473
+ dimensions: int = 1536,
474
+ metric: str = "cosine",
475
+ **kwargs: Any,
476
+ ) -> VectorStore:
477
+ """Return a configured pgvector vector store adapter from DI container."""
478
+ return cls._create_from_di("pgvector", collection, dimensions, metric, **kwargs)
479
+
480
+ @property
481
+ @abstractmethod
482
+ def capabilities(self) -> VectorStoreCapabilities:
483
+ """Return the declared capabilities supported by this adapter."""
484
+
485
+ @abstractmethod
486
+ async def create_collection(
487
+ self,
488
+ name: str,
489
+ dimensions: int,
490
+ metric: str = "cosine",
491
+ ) -> None:
492
+ """Create a collection / table configured for vectors of the specified dimension and metric."""
493
+
494
+ @abstractmethod
495
+ async def upsert(
496
+ self,
497
+ id: str,
498
+ vector: list[float],
499
+ metadata: dict[str, Any] | None = None,
500
+ content: str = "",
501
+ ) -> None:
502
+ """Insert or update a single vector record."""
503
+
504
+ @abstractmethod
505
+ async def upsert_batch(
506
+ self,
507
+ records: Sequence[
508
+ VectorRecord
509
+ | dict[str, Any]
510
+ | tuple[str, list[float], dict[str, Any] | None]
511
+ ],
512
+ ) -> None:
513
+ """Insert or update multiple vector records in batch."""
514
+
515
+ @abstractmethod
516
+ async def search(
517
+ self,
518
+ vector: list[float],
519
+ limit: int = 10,
520
+ filter: dict[str, Any] | None = None,
521
+ ) -> list[SearchResult]:
522
+ """Search the collection for vectors nearest to the query vector."""
523
+
524
+ @abstractmethod
525
+ async def delete(self, id: str) -> bool:
526
+ """Delete a vector record by id. Return True if deleted, False if not found."""
527
+
528
+ @abstractmethod
529
+ async def delete_batch(self, ids: Sequence[str]) -> int:
530
+ """Delete multiple vector records by ids. Return the number of deleted records."""
531
+
532
+ @abstractmethod
533
+ async def get(self, id: str) -> SearchResult | None:
534
+ """Retrieve a single vector record by id."""
535
+
536
+ @abstractmethod
537
+ async def count(self) -> int:
538
+ """Return the total number of records in the active collection."""
539
+
540
+ @abstractmethod
541
+ async def drop_collection(self, name: str) -> None:
542
+ """Drop a collection and all its stored records."""