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,353 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import time
5
+ from collections.abc import AsyncIterator
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ _ANTHROPIC_HTTP_ERRORS: tuple[type[Exception], ...] = (httpx.TimeoutException, httpx.RequestError)
11
+
12
+ from swiftpy.ai.exceptions import (
13
+ AIAuthenticationError,
14
+ AIConnectionError,
15
+ AIProviderError,
16
+ AIRateLimitError,
17
+ )
18
+ from swiftpy.ai.pool import AIHttpPool
19
+ from swiftpy.ai.providers.base import (
20
+ LLMRequest,
21
+ LLMResponse,
22
+ ProviderInterface,
23
+ TokenUsage,
24
+ ToolCall,
25
+ extract_error_message,
26
+ parse_retry_after,
27
+ strip_internal_extra,
28
+ )
29
+ from swiftpy.ai.streaming import parse_sse_events
30
+
31
+
32
+ def _coerce_tool_input(raw_args: Any) -> dict[str, Any]:
33
+ """Ensure tool_use input sent to Anthropic is always a JSON object dictionary."""
34
+ if isinstance(raw_args, dict):
35
+ return raw_args
36
+ if isinstance(raw_args, str):
37
+ try:
38
+ parsed = json.loads(raw_args)
39
+ if isinstance(parsed, dict):
40
+ return parsed
41
+ except (json.JSONDecodeError, ValueError):
42
+ pass
43
+ return {"raw": raw_args}
44
+ if raw_args is None:
45
+ return {}
46
+ return {"value": raw_args}
47
+
48
+
49
+ class AnthropicProvider(ProviderInterface):
50
+ """
51
+ Anthropic provider adapter mapping unified requests to the /v1/messages endpoint.
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ config: dict[str, Any] | None = None,
57
+ http_pool: AIHttpPool | None = None,
58
+ ) -> None:
59
+ super().__init__(config=config, http_pool=http_pool)
60
+ self._api_key: str = str(self.config.get("api_key", ""))
61
+ self._default_model: str = str(
62
+ self.config.get("model")
63
+ or self.config.get("default_model")
64
+ or "claude-3-5-sonnet-20241022"
65
+ )
66
+ self._base_url: str = str(
67
+ self.config.get("base_url", "https://api.anthropic.com/v1")
68
+ ).rstrip("/")
69
+ self._timeout: float = float(self.config.get("timeout", 60.0))
70
+ self._anthropic_version: str = str(
71
+ self.config.get("anthropic_version", "2023-06-01")
72
+ )
73
+
74
+ @property
75
+ def name(self) -> str:
76
+ return "anthropic"
77
+
78
+ def get_default_model(self) -> str:
79
+ return self._default_model
80
+
81
+ def _build_payload(
82
+ self,
83
+ request: LLMRequest,
84
+ *,
85
+ stream: bool = False,
86
+ ) -> tuple[str, dict[str, Any]]:
87
+ model = request.model or self.get_default_model()
88
+
89
+ system_parts: list[str] = []
90
+ dialogue_messages: list[dict[str, Any]] = []
91
+ for msg in request.messages:
92
+ if msg.role == "system":
93
+ system_parts.append(msg.content)
94
+ elif msg.role == "tool":
95
+ tool_result_block = {
96
+ "type": "tool_result",
97
+ "tool_use_id": msg.tool_call_id or "",
98
+ "content": msg.content,
99
+ }
100
+ if (
101
+ dialogue_messages
102
+ and dialogue_messages[-1]["role"] == "user"
103
+ and isinstance(dialogue_messages[-1]["content"], list)
104
+ and any(
105
+ isinstance(b, dict) and b.get("type") == "tool_result"
106
+ for b in dialogue_messages[-1]["content"]
107
+ )
108
+ ):
109
+ dialogue_messages[-1]["content"].append(tool_result_block)
110
+ else:
111
+ dialogue_messages.append({
112
+ "role": "user",
113
+ "content": [tool_result_block],
114
+ })
115
+ elif msg.role == "assistant" and msg.tool_calls:
116
+ content_blocks: list[dict[str, Any]] = []
117
+ if msg.content:
118
+ content_blocks.append({"type": "text", "text": msg.content})
119
+ for tc in msg.tool_calls:
120
+ content_blocks.append({
121
+ "type": "tool_use",
122
+ "id": tc.id,
123
+ "name": tc.name,
124
+ "input": _coerce_tool_input(tc.arguments),
125
+ })
126
+ dialogue_messages.append({"role": "assistant", "content": content_blocks})
127
+ else:
128
+ dialogue_messages.append({"role": msg.role, "content": msg.content})
129
+
130
+ max_tokens = (
131
+ request.max_tokens
132
+ if request.max_tokens is not None
133
+ else int(self.config.get("max_tokens", 1024))
134
+ )
135
+
136
+ payload: dict[str, Any] = {
137
+ "model": model,
138
+ "messages": dialogue_messages,
139
+ "max_tokens": max_tokens,
140
+ }
141
+ if stream:
142
+ payload["stream"] = True
143
+
144
+ if system_parts:
145
+ payload["system"] = "\n\n".join(system_parts)
146
+ if request.temperature is not None:
147
+ payload["temperature"] = request.temperature
148
+ if request.top_p is not None:
149
+ payload["top_p"] = request.top_p
150
+ if request.stop is not None:
151
+ payload["stop_sequences"] = request.stop
152
+ if request.tools is not None:
153
+ anthropic_tools: list[dict[str, Any]] = []
154
+ for t in request.tools:
155
+ if "input_schema" in t:
156
+ anthropic_tools.append(t)
157
+ elif "function" in t:
158
+ fn = t["function"]
159
+ anthropic_tools.append({
160
+ "name": fn.get("name"),
161
+ "description": fn.get("description", ""),
162
+ "input_schema": fn.get("parameters", {}),
163
+ })
164
+ elif "parameters" in t:
165
+ anthropic_tools.append({
166
+ "name": t.get("name"),
167
+ "description": t.get("description", ""),
168
+ "input_schema": t.get("parameters", {}),
169
+ })
170
+ else:
171
+ anthropic_tools.append(t)
172
+ payload["tools"] = anthropic_tools
173
+ if request.extra:
174
+ payload.update(strip_internal_extra(request.extra))
175
+
176
+ return model, payload
177
+
178
+ def _raise_for_http_error(self, response: httpx.Response, body_text: str, data: Any) -> None:
179
+ if response.status_code in (401, 403):
180
+ err_msg = extract_error_message(data, body_text) or "Authentication failed"
181
+ raise AIAuthenticationError(
182
+ f"Anthropic authentication failed ({response.status_code}): {err_msg}",
183
+ provider=self.name,
184
+ status_code=response.status_code,
185
+ response_body=body_text,
186
+ )
187
+ if response.status_code == 429:
188
+ retry_after = parse_retry_after(response.headers.get("Retry-After"))
189
+ err_msg = extract_error_message(data, body_text) or "Rate limit reached"
190
+ raise AIRateLimitError(
191
+ f"Anthropic rate limit exceeded: {err_msg}",
192
+ provider=self.name,
193
+ status_code=response.status_code,
194
+ response_body=body_text,
195
+ retry_after=retry_after,
196
+ )
197
+ err_msg = extract_error_message(data, body_text) or f"HTTP {response.status_code}"
198
+ raise AIProviderError(
199
+ f"Anthropic API error ({response.status_code}): {err_msg}",
200
+ provider=self.name,
201
+ status_code=response.status_code,
202
+ response_body=body_text,
203
+ )
204
+
205
+ async def send(self, request: LLMRequest) -> LLMResponse:
206
+ url = f"{self._base_url}/messages"
207
+ headers = {
208
+ "x-api-key": self._api_key,
209
+ "anthropic-version": self._anthropic_version,
210
+ "content-type": "application/json",
211
+ }
212
+ model, payload = self._build_payload(request, stream=False)
213
+ client = self._http_pool.get_client()
214
+
215
+ async def _attempt() -> tuple[httpx.Response, float, str, Any]:
216
+ start_time = time.perf_counter()
217
+ try:
218
+ response = await client.post(
219
+ url,
220
+ headers=headers,
221
+ json=payload,
222
+ timeout=self._timeout,
223
+ )
224
+ except _ANTHROPIC_HTTP_ERRORS as exc:
225
+ sanitized_exc = self.sanitize_error(str(exc))
226
+ raise AIConnectionError(
227
+ f"Failed to connect to Anthropic ({type(exc).__name__}): {sanitized_exc}",
228
+ provider=self.name,
229
+ ) from exc
230
+
231
+ latency_ms = (time.perf_counter() - start_time) * 1000
232
+ body_text = self.sanitize_error(response.text)
233
+ try:
234
+ data: Any = response.json()
235
+ except (json.JSONDecodeError, ValueError):
236
+ data = None
237
+
238
+ if response.status_code >= 400:
239
+ self._raise_for_http_error(response, body_text, data)
240
+ return response, latency_ms, body_text, data
241
+
242
+ response, latency_ms, body_text, data = await self._call_with_retries(_attempt)
243
+
244
+ if not isinstance(data, dict):
245
+ raise AIProviderError(
246
+ f"Anthropic returned non-JSON response ({response.status_code}): {body_text[:200]}",
247
+ provider=self.name,
248
+ status_code=response.status_code,
249
+ response_body=body_text,
250
+ )
251
+
252
+ raw_content = data.get("content")
253
+ text_parts: list[str] = []
254
+ extracted_tool_calls: list[ToolCall] = []
255
+ if isinstance(raw_content, list):
256
+ for block in raw_content:
257
+ if isinstance(block, dict):
258
+ b_type = block.get("type")
259
+ if b_type == "text":
260
+ text_parts.append(str(block.get("text") or ""))
261
+ elif b_type == "tool_use":
262
+ tc_id = str(block.get("id") or "")
263
+ tc_name = str(block.get("name") or "")
264
+ tc_input = block.get("input")
265
+ tc_args = tc_input if isinstance(tc_input, dict) else {}
266
+ extracted_tool_calls.append(ToolCall(id=tc_id, name=tc_name, arguments=tc_args))
267
+ content = "".join(text_parts)
268
+
269
+ raw_usage = data.get("usage")
270
+ if isinstance(raw_usage, dict):
271
+ input_tokens = int(raw_usage.get("input_tokens") or 0)
272
+ output_tokens = int(raw_usage.get("output_tokens") or 0)
273
+ usage = TokenUsage(
274
+ prompt_tokens=input_tokens,
275
+ completion_tokens=output_tokens,
276
+ total_tokens=input_tokens + output_tokens,
277
+ )
278
+ else:
279
+ usage = TokenUsage(prompt_tokens=0, completion_tokens=0, total_tokens=0)
280
+
281
+ finish_reason = data.get("stop_reason")
282
+ model_reported = str(data.get("model") or model)
283
+
284
+ return LLMResponse(
285
+ content=content,
286
+ role=str(data.get("role") or "assistant"),
287
+ model=model_reported,
288
+ usage=usage,
289
+ raw=data,
290
+ latency_ms=round(latency_ms, 2),
291
+ finish_reason=str(finish_reason) if finish_reason else None,
292
+ tool_calls=extracted_tool_calls,
293
+ )
294
+
295
+ async def stream(self, request: LLMRequest) -> AsyncIterator[str]:
296
+ url = f"{self._base_url}/messages"
297
+ headers = {
298
+ "x-api-key": self._api_key,
299
+ "anthropic-version": self._anthropic_version,
300
+ "content-type": "application/json",
301
+ "Accept": "text/event-stream",
302
+ }
303
+ _, payload = self._build_payload(request, stream=True)
304
+ client = self._http_pool.get_client()
305
+
306
+ async def _open_stream() -> httpx.Response:
307
+ try:
308
+ req = client.build_request(
309
+ "POST",
310
+ url,
311
+ headers=headers,
312
+ json=payload,
313
+ timeout=self._timeout,
314
+ )
315
+ resp = await client.send(req, stream=True)
316
+ except _ANTHROPIC_HTTP_ERRORS as exc:
317
+ sanitized_exc = self.sanitize_error(str(exc))
318
+ raise AIConnectionError(
319
+ f"Failed to connect to Anthropic stream ({type(exc).__name__}): {sanitized_exc}",
320
+ provider=self.name,
321
+ ) from exc
322
+
323
+ if resp.status_code >= 400:
324
+ raw_bytes = await resp.aread()
325
+ body_text = self.sanitize_error(raw_bytes.decode("utf-8", errors="replace"))
326
+ await resp.aclose()
327
+ try:
328
+ err_data: Any = json.loads(body_text)
329
+ except (json.JSONDecodeError, ValueError):
330
+ err_data = None
331
+ self._raise_for_http_error(resp, body_text, err_data)
332
+ return resp
333
+
334
+ response = await self._call_with_retries(_open_stream)
335
+
336
+ try:
337
+ async for event in parse_sse_events(response.aiter_lines()):
338
+ event_type = event.get("event")
339
+ data = event.get("data")
340
+ if isinstance(data, dict):
341
+ # Check for content_block_delta
342
+ data_type = data.get("type")
343
+ if event_type == "content_block_delta" or data_type == "content_block_delta":
344
+ delta = data.get("delta")
345
+ if isinstance(delta, dict) and delta.get("type") == "text_delta":
346
+ text = delta.get("text")
347
+ if text:
348
+ yield str(text)
349
+ elif event_type == "message_stop" or data_type == "message_stop":
350
+ break
351
+ finally:
352
+ await response.aclose()
353
+
@@ -0,0 +1,273 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import math
6
+ import random
7
+ import re
8
+ from abc import ABC, abstractmethod
9
+ from collections.abc import AsyncIterator, Awaitable, Callable
10
+ from dataclasses import dataclass, field
11
+ from datetime import UTC, datetime
12
+ from email.utils import parsedate_to_datetime
13
+ from typing import Any, TypeVar
14
+
15
+ from swiftpy.ai.exceptions import AIConnectionError, AIProviderError, AIRateLimitError
16
+ from swiftpy.ai.pool import AIHttpPool
17
+
18
+ INTERNAL_ATTRIBUTION_KEYS: frozenset[str] = frozenset({"user_id", "tenant_id", "user", "tenant"})
19
+
20
+ _T = TypeVar("_T")
21
+ _SK_TOKEN_RE = re.compile(r"sk-[A-Za-z0-9_-]{12,}")
22
+ _BEARER_TOKEN_RE = re.compile(r"Bearer\s+[A-Za-z0-9._\-+/=]{8,}", re.IGNORECASE)
23
+
24
+
25
+ def redact_sensitive_text(message: str, api_key: str = "") -> str:
26
+ """Redact API keys, Bearer tokens, and sk-* credentials from text."""
27
+ result = message
28
+ if api_key and len(api_key) > 6:
29
+ result = result.replace(api_key, "[REDACTED]")
30
+ result = _SK_TOKEN_RE.sub("[REDACTED]", result)
31
+ result = _BEARER_TOKEN_RE.sub("Bearer [REDACTED]", result)
32
+ return result
33
+
34
+
35
+ def parse_retry_after(header_value: str | None) -> float | None:
36
+ """Parse Retry-After header supporting both numeric seconds and HTTP-date."""
37
+ if not header_value:
38
+ return None
39
+ raw = str(header_value).strip()
40
+ if not raw:
41
+ return None
42
+ try:
43
+ seconds = float(raw)
44
+ if math.isfinite(seconds):
45
+ return max(0.0, seconds)
46
+ return None
47
+ except ValueError:
48
+ pass
49
+ try:
50
+ dt = parsedate_to_datetime(raw)
51
+ except (TypeError, ValueError):
52
+ return None
53
+ if dt is None:
54
+ return None
55
+ if dt.tzinfo is None:
56
+ dt = dt.replace(tzinfo=UTC)
57
+ delta = (dt - datetime.now(UTC)).total_seconds()
58
+ return max(0.0, round(delta, 2))
59
+
60
+
61
+ def extract_error_message(data: Any, fallback: str) -> str:
62
+ """Safely extract provider error message from parsed JSON without assuming dict shape."""
63
+ if isinstance(data, dict):
64
+ err = data.get("error")
65
+ if isinstance(err, dict):
66
+ msg = err.get("message")
67
+ if msg:
68
+ return str(msg)
69
+ elif isinstance(err, str) and err:
70
+ return err
71
+ msg_top = data.get("message")
72
+ if msg_top:
73
+ return str(msg_top)
74
+ return fallback
75
+
76
+
77
+ def strip_internal_extra(extra: dict[str, Any]) -> dict[str, Any]:
78
+ """Return copy of extra parameters with framework attribution keys removed."""
79
+ return {k: v for k, v in extra.items() if k not in INTERNAL_ATTRIBUTION_KEYS}
80
+
81
+ VALID_ROLES: frozenset[str] = frozenset({"system", "user", "assistant", "tool"})
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class ToolCall:
86
+ """Represents a tool call requested by an LLM."""
87
+
88
+ id: str
89
+ name: str
90
+ arguments: dict[str, Any] = field(default_factory=dict)
91
+
92
+ def to_dict(self) -> dict[str, Any]:
93
+ """Convert ToolCall to OpenAI-compatible function call representation."""
94
+ return {
95
+ "id": self.id,
96
+ "type": "function",
97
+ "function": {
98
+ "name": self.name,
99
+ "arguments": (
100
+ json.dumps(self.arguments)
101
+ if isinstance(self.arguments, dict)
102
+ else str(self.arguments)
103
+ ),
104
+ },
105
+ }
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class ToolResult:
110
+ """Represents the execution result of an invoked tool."""
111
+
112
+ tool_call_id: str
113
+ name: str
114
+ result: Any
115
+ is_error: bool = False
116
+
117
+
118
+ @dataclass(frozen=True)
119
+ class Message:
120
+ """Represents a single message in an LLM conversation."""
121
+
122
+ role: str
123
+ content: str = ""
124
+ name: str | None = None
125
+ tool_calls: list[ToolCall] | None = None
126
+ tool_call_id: str | None = None
127
+
128
+ def __post_init__(self) -> None:
129
+ if not isinstance(self.role, str) or self.role.lower() not in VALID_ROLES:
130
+ valid = ", ".join(sorted(f"'{r}'" for r in VALID_ROLES))
131
+ raise ValueError(
132
+ f"Invalid message role '{self.role}'. Expected one of: {valid}"
133
+ )
134
+ if not isinstance(self.content, str):
135
+ raise TypeError(
136
+ f"Message content must be a string, got {type(self.content).__name__}"
137
+ )
138
+ if not self.content.strip() and not self.tool_calls:
139
+ raise ValueError(
140
+ "Message content cannot be empty or whitespace only when no tool_calls are present"
141
+ )
142
+
143
+ def to_dict(self) -> dict[str, Any]:
144
+ payload: dict[str, Any] = {"role": self.role.lower(), "content": self.content}
145
+ if self.name:
146
+ payload["name"] = self.name
147
+ if self.tool_call_id:
148
+ payload["tool_call_id"] = self.tool_call_id
149
+ if self.tool_calls:
150
+ payload["tool_calls"] = [tc.to_dict() for tc in self.tool_calls]
151
+ return payload
152
+
153
+
154
+ @dataclass(frozen=True)
155
+ class TokenUsage:
156
+ """Telemetry detailing token consumption for a completion."""
157
+
158
+ prompt_tokens: int = 0
159
+ completion_tokens: int = 0
160
+ total_tokens: int = 0
161
+
162
+
163
+ @dataclass
164
+ class LLMRequest:
165
+ """Unified request parameters passed to an LLM provider adapter."""
166
+
167
+ messages: list[Message]
168
+ model: str | None = None
169
+ temperature: float | None = None
170
+ max_tokens: int | None = None
171
+ top_p: float | None = None
172
+ stop: list[str] | None = None
173
+ tools: list[dict[str, Any]] | None = None
174
+ extra: dict[str, Any] = field(default_factory=dict)
175
+ metadata: dict[str, Any] = field(default_factory=dict)
176
+
177
+
178
+ @dataclass(frozen=True)
179
+ class LLMResponse:
180
+ """Unified completion response returned by an LLM provider adapter."""
181
+
182
+ content: str = ""
183
+ role: str = "assistant"
184
+ model: str = ""
185
+ usage: TokenUsage = field(default_factory=TokenUsage)
186
+ raw: dict[str, Any] = field(default_factory=dict)
187
+ latency_ms: float = 0.0
188
+ finish_reason: str | None = None
189
+ tool_calls: list[ToolCall] = field(default_factory=list)
190
+
191
+
192
+
193
+ class ProviderInterface(ABC):
194
+ """
195
+ Contract every LLM provider adapter must implement.
196
+ """
197
+
198
+ def __init__(
199
+ self,
200
+ config: dict[str, Any] | None = None,
201
+ http_pool: AIHttpPool | None = None,
202
+ ) -> None:
203
+ self.config: dict[str, Any] = config or {}
204
+ self._http_pool = http_pool or AIHttpPool.get_instance()
205
+
206
+ @property
207
+ @abstractmethod
208
+ def name(self) -> str:
209
+ """Provider identifier (e.g. 'openai', 'anthropic')."""
210
+
211
+ @abstractmethod
212
+ def get_default_model(self) -> str:
213
+ """Return the default model name configured for this provider."""
214
+
215
+ @abstractmethod
216
+ async def send(self, request: LLMRequest) -> LLMResponse:
217
+ """
218
+ Execute a completion request against the provider's API.
219
+
220
+ Maps the unified request shape onto the provider's native payload,
221
+ executes the call via the shared HTTP pool, and transforms the response.
222
+ """
223
+
224
+ @abstractmethod
225
+ def stream(self, request: LLMRequest) -> AsyncIterator[str]:
226
+ """
227
+ Execute a streaming completion request against the provider's API.
228
+
229
+ Yields string token chunks incrementally as they are emitted by the model.
230
+ """
231
+
232
+ def sanitize_error(self, message: str) -> str:
233
+ """Sanitize error messages to avoid leaking API credentials in logs/exceptions."""
234
+ return redact_sensitive_text(
235
+ message,
236
+ api_key=str(self.config.get("api_key", "")),
237
+ )
238
+
239
+ def _max_retries(self) -> int:
240
+ raw = self.config.get("max_retries", 0)
241
+ if isinstance(raw, bool):
242
+ return 0
243
+ try:
244
+ return max(0, int(raw))
245
+ except (TypeError, ValueError):
246
+ return 0
247
+
248
+ async def _call_with_retries(self, fn: Callable[[], Awaitable[_T]]) -> _T:
249
+ """Execute an async provider operation honoring configured max_retries."""
250
+ max_retries = self._max_retries()
251
+ base_delay = float(self.config.get("retry_delay", 0.05))
252
+ max_delay = float(self.config.get("max_retry_delay", 2.0))
253
+
254
+ attempt = 0
255
+ while True:
256
+ try:
257
+ return await fn()
258
+ except (AIConnectionError, AIRateLimitError, AIProviderError) as exc:
259
+ retryable = isinstance(exc, (AIConnectionError, AIRateLimitError)) or (
260
+ exc.status_code is not None and exc.status_code >= 500
261
+ )
262
+ if attempt >= max_retries or not retryable:
263
+ raise
264
+
265
+ if isinstance(exc, AIRateLimitError) and exc.retry_after is not None:
266
+ delay = min(max(0.0, float(exc.retry_after)), max_delay)
267
+ else:
268
+ delay = min(base_delay * (2**attempt), max_delay) + random.uniform(
269
+ 0.0, min(0.02, base_delay)
270
+ )
271
+ attempt += 1
272
+ if delay > 0:
273
+ await asyncio.sleep(delay)