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.
- swiftpy/.gitignore +0 -0
- swiftpy/__init__.py +572 -0
- swiftpy/admin/__init__.py +50 -0
- swiftpy/admin/auth.py +522 -0
- swiftpy/admin/registry.py +567 -0
- swiftpy/admin/views.py +1583 -0
- swiftpy/ai/__init__.py +199 -0
- swiftpy/ai/_deterministic.py +40 -0
- swiftpy/ai/_fake.py +604 -0
- swiftpy/ai/agents/__init__.py +30 -0
- swiftpy/ai/agents/base_agent.py +506 -0
- swiftpy/ai/agents/memory.py +864 -0
- swiftpy/ai/client.py +1011 -0
- swiftpy/ai/conversation.py +171 -0
- swiftpy/ai/embeddings.py +321 -0
- swiftpy/ai/exceptions.py +75 -0
- swiftpy/ai/history_strategies.py +157 -0
- swiftpy/ai/middleware.py +918 -0
- swiftpy/ai/observability/__init__.py +28 -0
- swiftpy/ai/observability/drivers.py +763 -0
- swiftpy/ai/observability/observer.py +364 -0
- swiftpy/ai/pool.py +102 -0
- swiftpy/ai/pricing.py +101 -0
- swiftpy/ai/prompts/__init__.py +6 -0
- swiftpy/ai/prompts/loader.py +343 -0
- swiftpy/ai/providers/__init__.py +22 -0
- swiftpy/ai/providers/anthropic_provider.py +353 -0
- swiftpy/ai/providers/base.py +273 -0
- swiftpy/ai/providers/openai_provider.py +275 -0
- swiftpy/ai/rag/__init__.py +53 -0
- swiftpy/ai/rag/context_formatter.py +263 -0
- swiftpy/ai/rag/pipeline_builder.py +544 -0
- swiftpy/ai/rag/reranker.py +484 -0
- swiftpy/ai/rag/result.py +78 -0
- swiftpy/ai/rag/retriever.py +315 -0
- swiftpy/ai/redis_store.py +227 -0
- swiftpy/ai/streaming.py +115 -0
- swiftpy/ai/tools/__init__.py +22 -0
- swiftpy/ai/tools/base_tool.py +489 -0
- swiftpy/ai/tools/first_party.py +384 -0
- swiftpy/ai/tools/tool_loop.py +417 -0
- swiftpy/ai/vectorstore/__init__.py +46 -0
- swiftpy/ai/vectorstore/base.py +542 -0
- swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
- swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
- swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
- swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
- swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
- swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
- swiftpy/auth/__init__.py +50 -0
- swiftpy/auth/api_key_guard.py +544 -0
- swiftpy/auth/auth_manager.py +259 -0
- swiftpy/auth/exceptions.py +71 -0
- swiftpy/auth/jwt_guard.py +230 -0
- swiftpy/auth/jwt_issuer.py +658 -0
- swiftpy/auth/token_blacklist.py +300 -0
- swiftpy/cache/__init__.py +14 -0
- swiftpy/cache/cache.py +251 -0
- swiftpy/cache/driver.py +216 -0
- swiftpy/cache/drivers/memory.py +199 -0
- swiftpy/cache/drivers/redis.py +421 -0
- swiftpy/console/__init__.py +0 -0
- swiftpy/console/cli.py +68 -0
- swiftpy/console/commands/__init__.py +0 -0
- swiftpy/console/commands/cache.py +164 -0
- swiftpy/console/commands/make.py +935 -0
- swiftpy/console/commands/migrate.py +280 -0
- swiftpy/console/commands/new.py +1806 -0
- swiftpy/console/commands/queue.py +306 -0
- swiftpy/console/commands/seed.py +252 -0
- swiftpy/console/commands/start.py +171 -0
- swiftpy/console/commands/storage.py +104 -0
- swiftpy/console/loader.py +21 -0
- swiftpy/console/stubs/__init__.py +26 -0
- swiftpy/console/stubs/controller.stub +14 -0
- swiftpy/console/stubs/event.stub +14 -0
- swiftpy/console/stubs/job.stub +22 -0
- swiftpy/console/stubs/listener.stub +14 -0
- swiftpy/console/stubs/mail.stub +20 -0
- swiftpy/console/stubs/middleware.stub +16 -0
- swiftpy/console/stubs/migration.alter.stub +17 -0
- swiftpy/console/stubs/migration.api_keys.stub +22 -0
- swiftpy/console/stubs/migration.blank.stub +11 -0
- swiftpy/console/stubs/migration.create.stub +15 -0
- swiftpy/console/stubs/migration.stub +11 -0
- swiftpy/console/stubs/migration.update.stub +17 -0
- swiftpy/console/stubs/model.stub +17 -0
- swiftpy/console/stubs/provider.stub +18 -0
- swiftpy/console/stubs/repository.stub +10 -0
- swiftpy/console/stubs/request.stub +16 -0
- swiftpy/console/stubs/resource.stub +21 -0
- swiftpy/console/stubs/schema.stub +12 -0
- swiftpy/console/stubs/seeder.stub +14 -0
- swiftpy/console/stubs/service.stub +10 -0
- swiftpy/core/__init__.py +0 -0
- swiftpy/core/application.py +1306 -0
- swiftpy/core/bootstrap.py +30 -0
- swiftpy/core/config.py +432 -0
- swiftpy/core/container.py +596 -0
- swiftpy/core/container_exceptions.py +18 -0
- swiftpy/core/context.py +148 -0
- swiftpy/core/exceptions.py +352 -0
- swiftpy/core/providers/base.py +35 -0
- swiftpy/database/__init__.py +41 -0
- swiftpy/database/exceptions.py +42 -0
- swiftpy/database/identifiers.py +105 -0
- swiftpy/database/migrations/__init__.py +36 -0
- swiftpy/database/migrations/blueprint.py +431 -0
- swiftpy/database/migrations/lock.py +104 -0
- swiftpy/database/migrations/migration.py +22 -0
- swiftpy/database/migrations/migrator.py +507 -0
- swiftpy/database/migrations/schema.py +435 -0
- swiftpy/database/orm/__init__.py +33 -0
- swiftpy/database/orm/fields.py +92 -0
- swiftpy/database/orm/model.py +2034 -0
- swiftpy/database/orm/registry.py +33 -0
- swiftpy/database/orm/relationships.py +836 -0
- swiftpy/database/pagination/__init__.py +16 -0
- swiftpy/database/pagination/contracts.py +60 -0
- swiftpy/database/pagination/paginator.py +112 -0
- swiftpy/database/pagination/simple_paginator.py +97 -0
- swiftpy/database/pool.py +218 -0
- swiftpy/database/query_builder.py +1803 -0
- swiftpy/database/seeder.py +39 -0
- swiftpy/database/transaction.py +276 -0
- swiftpy/events/__init__.py +10 -0
- swiftpy/events/dispatcher.py +308 -0
- swiftpy/events/redis_bus.py +552 -0
- swiftpy/filesystem/__init__.py +227 -0
- swiftpy/filesystem/disk.py +378 -0
- swiftpy/filesystem/drivers/__init__.py +11 -0
- swiftpy/filesystem/drivers/base.py +156 -0
- swiftpy/filesystem/drivers/local.py +559 -0
- swiftpy/filesystem/drivers/s3.py +655 -0
- swiftpy/filesystem/events.py +37 -0
- swiftpy/filesystem/exceptions.py +127 -0
- swiftpy/filesystem/manager.py +138 -0
- swiftpy/filesystem/metadata.py +22 -0
- swiftpy/filesystem/paths.py +99 -0
- swiftpy/filesystem/service_provider.py +17 -0
- swiftpy/filesystem/uploads/__init__.py +13 -0
- swiftpy/filesystem/uploads/handlers.py +362 -0
- swiftpy/filesystem/uploads/uploaded_file.py +222 -0
- swiftpy/filesystem/uploads/validators.py +186 -0
- swiftpy/filesystem/visibility.py +22 -0
- swiftpy/helpers/__init__.py +42 -0
- swiftpy/helpers/helper.py +180 -0
- swiftpy/http/exceptions.py +41 -0
- swiftpy/http/middleware.py +307 -0
- swiftpy/http/request.py +375 -0
- swiftpy/http/response.py +442 -0
- swiftpy/http/router.py +1050 -0
- swiftpy/mail/__init__.py +58 -0
- swiftpy/mail/attachments.py +255 -0
- swiftpy/mail/config.py +71 -0
- swiftpy/mail/envelope.py +183 -0
- swiftpy/mail/exceptions.py +90 -0
- swiftpy/mail/facade.py +312 -0
- swiftpy/mail/mailable.py +53 -0
- swiftpy/mail/manager.py +240 -0
- swiftpy/mail/message.py +186 -0
- swiftpy/mail/models.py +19 -0
- swiftpy/mail/queue/__init__.py +5 -0
- swiftpy/mail/queue/job.py +279 -0
- swiftpy/mail/service_provider.py +19 -0
- swiftpy/mail/templates/__init__.py +5 -0
- swiftpy/mail/templates/renderer.py +173 -0
- swiftpy/mail/transports/__init__.py +21 -0
- swiftpy/mail/transports/api/__init__.py +13 -0
- swiftpy/mail/transports/api/base.py +194 -0
- swiftpy/mail/transports/api/mailgun.py +158 -0
- swiftpy/mail/transports/api/resend.py +156 -0
- swiftpy/mail/transports/api/sendgrid.py +172 -0
- swiftpy/mail/transports/base.py +24 -0
- swiftpy/mail/transports/console.py +91 -0
- swiftpy/mail/transports/sendmail.py +128 -0
- swiftpy/mail/transports/smtp.py +466 -0
- swiftpy/queue/__init__.py +13 -0
- swiftpy/queue/dispatcher.py +813 -0
- swiftpy/queue/job.py +541 -0
- swiftpy/queue/worker.py +556 -0
- swiftpy/support/__init__.py +12 -0
- swiftpy/support/keys.py +108 -0
- swiftpy/support/redis_config.py +223 -0
- swiftpy/support/strings.py +114 -0
- swiftpy/testing/__init__.py +20 -0
- swiftpy/testing/fakes/__init__.py +11 -0
- swiftpy/testing/fakes/ai_fake.py +21 -0
- swiftpy/testing/fakes/vectorstore_fake.py +194 -0
- swiftpy/testing/fixtures.py +124 -0
- swiftpy/testing/http_client.py +730 -0
- swiftpy/testing/test_case.py +126 -0
- swiftpy/validation/__init__.py +97 -0
- swiftpy/validation/exceptions.py +26 -0
- swiftpy/validation/form_request.py +302 -0
- swiftpy/validation/protocols.py +33 -0
- swiftpy/validation/rules/__init__.py +89 -0
- swiftpy/validation/rules/builtin.py +391 -0
- swiftpy/validation/rules/exists.py +86 -0
- swiftpy/validation/rules/registry.py +290 -0
- swiftpy/validation/rules/required.py +55 -0
- swiftpy/validation/rules/unique.py +138 -0
- swiftpy/validation/validator.py +612 -0
- swiftpy/websockets/__init__.py +19 -0
- swiftpy/websockets/connection.py +317 -0
- swiftpy/websockets/manager.py +271 -0
- swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
- swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
- swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
- swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
- swiftpy_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,275 @@
|
|
|
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
|
+
_OPENAI_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
|
+
class OpenAIProvider(ProviderInterface):
|
|
33
|
+
"""
|
|
34
|
+
OpenAI provider adapter mapping unified requests to the /v1/chat/completions endpoint.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
config: dict[str, Any] | None = None,
|
|
40
|
+
http_pool: AIHttpPool | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
super().__init__(config=config, http_pool=http_pool)
|
|
43
|
+
self._api_key: str = str(self.config.get("api_key", ""))
|
|
44
|
+
self._default_model: str = str(
|
|
45
|
+
self.config.get("model") or self.config.get("default_model") or "gpt-4o"
|
|
46
|
+
)
|
|
47
|
+
self._base_url: str = str(
|
|
48
|
+
self.config.get("base_url", "https://api.openai.com/v1")
|
|
49
|
+
).rstrip("/")
|
|
50
|
+
self._timeout: float = float(self.config.get("timeout", 60.0))
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def name(self) -> str:
|
|
54
|
+
return "openai"
|
|
55
|
+
|
|
56
|
+
def get_default_model(self) -> str:
|
|
57
|
+
return self._default_model
|
|
58
|
+
|
|
59
|
+
def _build_payload(self, request: LLMRequest, *, stream: bool = False) -> tuple[str, dict[str, Any]]:
|
|
60
|
+
model = request.model or self.get_default_model()
|
|
61
|
+
payload: dict[str, Any] = {
|
|
62
|
+
"model": model,
|
|
63
|
+
"messages": [m.to_dict() for m in request.messages],
|
|
64
|
+
}
|
|
65
|
+
if stream:
|
|
66
|
+
payload["stream"] = True
|
|
67
|
+
if request.temperature is not None:
|
|
68
|
+
payload["temperature"] = request.temperature
|
|
69
|
+
if request.max_tokens is not None:
|
|
70
|
+
payload["max_tokens"] = request.max_tokens
|
|
71
|
+
if request.top_p is not None:
|
|
72
|
+
payload["top_p"] = request.top_p
|
|
73
|
+
if request.stop is not None:
|
|
74
|
+
payload["stop"] = request.stop
|
|
75
|
+
if request.tools is not None:
|
|
76
|
+
payload["tools"] = request.tools
|
|
77
|
+
if request.extra:
|
|
78
|
+
payload.update(strip_internal_extra(request.extra))
|
|
79
|
+
return model, payload
|
|
80
|
+
|
|
81
|
+
def _raise_for_http_error(self, response: httpx.Response, body_text: str, data: Any) -> None:
|
|
82
|
+
if response.status_code in (401, 403):
|
|
83
|
+
err_msg = extract_error_message(data, body_text) or "Authentication failed"
|
|
84
|
+
raise AIAuthenticationError(
|
|
85
|
+
f"OpenAI authentication failed ({response.status_code}): {err_msg}",
|
|
86
|
+
provider=self.name,
|
|
87
|
+
status_code=response.status_code,
|
|
88
|
+
response_body=body_text,
|
|
89
|
+
)
|
|
90
|
+
if response.status_code == 429:
|
|
91
|
+
retry_after = parse_retry_after(response.headers.get("Retry-After"))
|
|
92
|
+
err_msg = extract_error_message(data, body_text) or "Rate limit reached"
|
|
93
|
+
raise AIRateLimitError(
|
|
94
|
+
f"OpenAI rate limit exceeded: {err_msg}",
|
|
95
|
+
provider=self.name,
|
|
96
|
+
status_code=response.status_code,
|
|
97
|
+
response_body=body_text,
|
|
98
|
+
retry_after=retry_after,
|
|
99
|
+
)
|
|
100
|
+
err_msg = extract_error_message(data, body_text) or f"HTTP {response.status_code}"
|
|
101
|
+
raise AIProviderError(
|
|
102
|
+
f"OpenAI API error ({response.status_code}): {err_msg}",
|
|
103
|
+
provider=self.name,
|
|
104
|
+
status_code=response.status_code,
|
|
105
|
+
response_body=body_text,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
async def send(self, request: LLMRequest) -> LLMResponse:
|
|
109
|
+
url = f"{self._base_url}/chat/completions"
|
|
110
|
+
headers = {
|
|
111
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
112
|
+
"Content-Type": "application/json",
|
|
113
|
+
}
|
|
114
|
+
model, payload = self._build_payload(request, stream=False)
|
|
115
|
+
client = self._http_pool.get_client()
|
|
116
|
+
|
|
117
|
+
async def _attempt() -> tuple[httpx.Response, float, str, Any]:
|
|
118
|
+
start_time = time.perf_counter()
|
|
119
|
+
try:
|
|
120
|
+
response = await client.post(
|
|
121
|
+
url,
|
|
122
|
+
headers=headers,
|
|
123
|
+
json=payload,
|
|
124
|
+
timeout=self._timeout,
|
|
125
|
+
)
|
|
126
|
+
except _OPENAI_HTTP_ERRORS as exc:
|
|
127
|
+
sanitized_exc = self.sanitize_error(str(exc))
|
|
128
|
+
raise AIConnectionError(
|
|
129
|
+
f"Failed to connect to OpenAI ({type(exc).__name__}): {sanitized_exc}",
|
|
130
|
+
provider=self.name,
|
|
131
|
+
) from exc
|
|
132
|
+
|
|
133
|
+
latency_ms = (time.perf_counter() - start_time) * 1000
|
|
134
|
+
body_text = self.sanitize_error(response.text)
|
|
135
|
+
try:
|
|
136
|
+
data: Any = response.json()
|
|
137
|
+
except (json.JSONDecodeError, ValueError):
|
|
138
|
+
data = None
|
|
139
|
+
|
|
140
|
+
if response.status_code >= 400:
|
|
141
|
+
self._raise_for_http_error(response, body_text, data)
|
|
142
|
+
return response, latency_ms, body_text, data
|
|
143
|
+
|
|
144
|
+
response, latency_ms, body_text, data = await self._call_with_retries(_attempt)
|
|
145
|
+
|
|
146
|
+
if not isinstance(data, dict):
|
|
147
|
+
raise AIProviderError(
|
|
148
|
+
f"OpenAI returned non-JSON response ({response.status_code}): {body_text[:200]}",
|
|
149
|
+
provider=self.name,
|
|
150
|
+
status_code=response.status_code,
|
|
151
|
+
response_body=body_text,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
choices = data.get("choices")
|
|
155
|
+
if not isinstance(choices, list) or len(choices) == 0:
|
|
156
|
+
raise AIProviderError(
|
|
157
|
+
"OpenAI returned a response with no choices",
|
|
158
|
+
provider=self.name,
|
|
159
|
+
status_code=response.status_code,
|
|
160
|
+
response_body=body_text,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
choice = choices[0]
|
|
164
|
+
if not isinstance(choice, dict):
|
|
165
|
+
raise AIProviderError(
|
|
166
|
+
"OpenAI returned malformed choice object",
|
|
167
|
+
provider=self.name,
|
|
168
|
+
status_code=response.status_code,
|
|
169
|
+
response_body=body_text,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
msg = choice.get("message") or {}
|
|
173
|
+
content = str(msg.get("content") or "") if isinstance(msg, dict) else ""
|
|
174
|
+
role = str(msg.get("role") or "assistant") if isinstance(msg, dict) else "assistant"
|
|
175
|
+
finish_reason = choice.get("finish_reason")
|
|
176
|
+
|
|
177
|
+
extracted_tool_calls: list[ToolCall] = []
|
|
178
|
+
raw_tool_calls = msg.get("tool_calls") if isinstance(msg, dict) else None
|
|
179
|
+
if isinstance(raw_tool_calls, list):
|
|
180
|
+
for tc in raw_tool_calls:
|
|
181
|
+
if isinstance(tc, dict):
|
|
182
|
+
tc_id = str(tc.get("id") or "")
|
|
183
|
+
fn = tc.get("function") or {}
|
|
184
|
+
fn_name = str(fn.get("name") or "") if isinstance(fn, dict) else ""
|
|
185
|
+
args_raw = fn.get("arguments") if isinstance(fn, dict) else {}
|
|
186
|
+
if isinstance(args_raw, str):
|
|
187
|
+
try:
|
|
188
|
+
args_parsed = json.loads(args_raw)
|
|
189
|
+
except (json.JSONDecodeError, TypeError):
|
|
190
|
+
args_parsed = {"raw": args_raw}
|
|
191
|
+
elif isinstance(args_raw, dict):
|
|
192
|
+
args_parsed = args_raw
|
|
193
|
+
else:
|
|
194
|
+
args_parsed = {}
|
|
195
|
+
extracted_tool_calls.append(
|
|
196
|
+
ToolCall(id=tc_id, name=fn_name, arguments=args_parsed)
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
raw_usage = data.get("usage")
|
|
200
|
+
if isinstance(raw_usage, dict):
|
|
201
|
+
usage = TokenUsage(
|
|
202
|
+
prompt_tokens=int(raw_usage.get("prompt_tokens") or 0),
|
|
203
|
+
completion_tokens=int(raw_usage.get("completion_tokens") or 0),
|
|
204
|
+
total_tokens=int(raw_usage.get("total_tokens") or 0),
|
|
205
|
+
)
|
|
206
|
+
else:
|
|
207
|
+
usage = TokenUsage(prompt_tokens=0, completion_tokens=0, total_tokens=0)
|
|
208
|
+
|
|
209
|
+
model_reported = str(data.get("model") or model)
|
|
210
|
+
|
|
211
|
+
return LLMResponse(
|
|
212
|
+
content=content,
|
|
213
|
+
role=role,
|
|
214
|
+
model=model_reported,
|
|
215
|
+
usage=usage,
|
|
216
|
+
raw=data,
|
|
217
|
+
latency_ms=round(latency_ms, 2),
|
|
218
|
+
finish_reason=str(finish_reason) if finish_reason else None,
|
|
219
|
+
tool_calls=extracted_tool_calls,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
async def stream(self, request: LLMRequest) -> AsyncIterator[str]:
|
|
223
|
+
url = f"{self._base_url}/chat/completions"
|
|
224
|
+
headers = {
|
|
225
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
226
|
+
"Content-Type": "application/json",
|
|
227
|
+
"Accept": "text/event-stream",
|
|
228
|
+
}
|
|
229
|
+
_, payload = self._build_payload(request, stream=True)
|
|
230
|
+
client = self._http_pool.get_client()
|
|
231
|
+
|
|
232
|
+
async def _open_stream() -> httpx.Response:
|
|
233
|
+
try:
|
|
234
|
+
req = client.build_request(
|
|
235
|
+
"POST",
|
|
236
|
+
url,
|
|
237
|
+
headers=headers,
|
|
238
|
+
json=payload,
|
|
239
|
+
timeout=self._timeout,
|
|
240
|
+
)
|
|
241
|
+
resp = await client.send(req, stream=True)
|
|
242
|
+
except _OPENAI_HTTP_ERRORS as exc:
|
|
243
|
+
sanitized_exc = self.sanitize_error(str(exc))
|
|
244
|
+
raise AIConnectionError(
|
|
245
|
+
f"Failed to connect to OpenAI stream ({type(exc).__name__}): {sanitized_exc}",
|
|
246
|
+
provider=self.name,
|
|
247
|
+
) from exc
|
|
248
|
+
|
|
249
|
+
if resp.status_code >= 400:
|
|
250
|
+
raw_bytes = await resp.aread()
|
|
251
|
+
body_text = self.sanitize_error(raw_bytes.decode("utf-8", errors="replace"))
|
|
252
|
+
await resp.aclose()
|
|
253
|
+
try:
|
|
254
|
+
err_data: Any = json.loads(body_text)
|
|
255
|
+
except (json.JSONDecodeError, ValueError):
|
|
256
|
+
err_data = None
|
|
257
|
+
self._raise_for_http_error(resp, body_text, err_data)
|
|
258
|
+
return resp
|
|
259
|
+
|
|
260
|
+
response = await self._call_with_retries(_open_stream)
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
async for event in parse_sse_events(response.aiter_lines()):
|
|
264
|
+
data = event.get("data")
|
|
265
|
+
if isinstance(data, dict):
|
|
266
|
+
choices = data.get("choices")
|
|
267
|
+
if isinstance(choices, list) and choices:
|
|
268
|
+
delta = choices[0].get("delta")
|
|
269
|
+
if isinstance(delta, dict):
|
|
270
|
+
content = delta.get("content")
|
|
271
|
+
if content and isinstance(content, str):
|
|
272
|
+
yield content
|
|
273
|
+
finally:
|
|
274
|
+
await response.aclose()
|
|
275
|
+
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from swiftpy.ai.rag.context_formatter import (
|
|
4
|
+
ContextFormatter,
|
|
5
|
+
NumberedListFormatter,
|
|
6
|
+
PlainConcatenationFormatter,
|
|
7
|
+
XMLBlocksFormatter,
|
|
8
|
+
)
|
|
9
|
+
from swiftpy.ai.rag.pipeline_builder import (
|
|
10
|
+
DEFAULT_PROMPT_TEMPLATE,
|
|
11
|
+
DEFAULT_SYSTEM_PROMPT,
|
|
12
|
+
RAGPipelineBuilder,
|
|
13
|
+
)
|
|
14
|
+
from swiftpy.ai.rag.reranker import (
|
|
15
|
+
BaseReranker,
|
|
16
|
+
CohereReranker,
|
|
17
|
+
CrossEncoderReranker,
|
|
18
|
+
)
|
|
19
|
+
from swiftpy.ai.rag.result import (
|
|
20
|
+
DEFAULT_MODEL_PRICING,
|
|
21
|
+
RAGResult,
|
|
22
|
+
calculate_token_cost,
|
|
23
|
+
register_model_pricing,
|
|
24
|
+
)
|
|
25
|
+
from swiftpy.ai.rag.retriever import Retriever, chunk_text, truncate_to_token_budget
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def rag() -> RAGPipelineBuilder:
|
|
29
|
+
"""Convenience factory returning a fluent RAGPipelineBuilder."""
|
|
30
|
+
return RAGPipelineBuilder()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"DEFAULT_MODEL_PRICING",
|
|
35
|
+
"DEFAULT_PROMPT_TEMPLATE",
|
|
36
|
+
"DEFAULT_SYSTEM_PROMPT",
|
|
37
|
+
"BaseReranker",
|
|
38
|
+
"CohereReranker",
|
|
39
|
+
"ContextFormatter",
|
|
40
|
+
"CrossEncoderReranker",
|
|
41
|
+
"NumberedListFormatter",
|
|
42
|
+
"PlainConcatenationFormatter",
|
|
43
|
+
"RAGPipelineBuilder",
|
|
44
|
+
"RAGResult",
|
|
45
|
+
"Retriever",
|
|
46
|
+
"XMLBlocksFormatter",
|
|
47
|
+
"calculate_token_cost",
|
|
48
|
+
"chunk_text",
|
|
49
|
+
"rag",
|
|
50
|
+
"register_model_pricing",
|
|
51
|
+
"truncate_to_token_budget",
|
|
52
|
+
]
|
|
53
|
+
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import html
|
|
4
|
+
import re
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from swiftpy.ai.vectorstore.base import SearchResult
|
|
10
|
+
|
|
11
|
+
_CONTEXT_BOUNDARY_RE = re.compile(
|
|
12
|
+
r"-{3,}\s*(BEGIN|END)\s+UNTRUSTED\s+REFERENCE\s+CONTEXT\s*-{3,}",
|
|
13
|
+
re.IGNORECASE,
|
|
14
|
+
)
|
|
15
|
+
_NONCE_BOUNDARY_RE = re.compile(
|
|
16
|
+
r"<<<\s*(?:END\s+)?CONTEXT_[A-Za-z0-9_-]*>>>",
|
|
17
|
+
re.IGNORECASE,
|
|
18
|
+
)
|
|
19
|
+
_XML_TAG_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_.-]*$")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def escape_context_boundaries(text: str) -> str:
|
|
23
|
+
"""Neutralize embedded context boundary delimiters in untrusted text."""
|
|
24
|
+
escaped = _CONTEXT_BOUNDARY_RE.sub(
|
|
25
|
+
lambda m: f"[escaped: {m.group(1).upper()} UNTRUSTED REFERENCE CONTEXT]",
|
|
26
|
+
text,
|
|
27
|
+
)
|
|
28
|
+
return _NONCE_BOUNDARY_RE.sub("[escaped: CONTEXT BOUNDARY]", escaped)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _extract_doc_content(
|
|
32
|
+
doc: SearchResult | str | dict[str, Any],
|
|
33
|
+
index: int,
|
|
34
|
+
) -> tuple[str, str, dict[str, Any], float]:
|
|
35
|
+
"""
|
|
36
|
+
Extract (content, id, metadata, score) from a document input.
|
|
37
|
+
"""
|
|
38
|
+
if isinstance(doc, SearchResult):
|
|
39
|
+
return doc.content, doc.id, doc.metadata, doc.score
|
|
40
|
+
if isinstance(doc, str):
|
|
41
|
+
return doc, f"doc_{index}", {}, 0.0
|
|
42
|
+
if isinstance(doc, dict):
|
|
43
|
+
return (
|
|
44
|
+
str(doc.get("content", "")),
|
|
45
|
+
str(doc.get("id", f"doc_{index}")),
|
|
46
|
+
dict(doc.get("metadata") or {}),
|
|
47
|
+
float(doc.get("score", 0.0)),
|
|
48
|
+
)
|
|
49
|
+
raise TypeError(f"Unsupported document type at index {index}: {type(doc).__name__}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ContextFormatter(ABC):
|
|
53
|
+
"""
|
|
54
|
+
Abstract contract for context formatting strategies in RAG pipelines.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
@abstractmethod
|
|
58
|
+
def format(
|
|
59
|
+
self,
|
|
60
|
+
documents: Sequence[SearchResult | str | dict[str, Any]],
|
|
61
|
+
) -> str:
|
|
62
|
+
"""
|
|
63
|
+
Format a sequence of retrieved chunks into a prompt-ready context string.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def create(cls, strategy: str | ContextFormatter) -> ContextFormatter:
|
|
68
|
+
"""
|
|
69
|
+
Factory method resolving a formatter instance from a strategy name or instance.
|
|
70
|
+
|
|
71
|
+
Supported strategies:
|
|
72
|
+
- 'numbered', 'numbered_list' -> NumberedListFormatter
|
|
73
|
+
- 'xml', 'xml_blocks' -> XMLBlocksFormatter
|
|
74
|
+
- 'plain', 'concatenation', 'plain_concatenation' -> PlainConcatenationFormatter
|
|
75
|
+
"""
|
|
76
|
+
if isinstance(strategy, ContextFormatter):
|
|
77
|
+
return strategy
|
|
78
|
+
|
|
79
|
+
norm = str(strategy).lower().strip().replace("-", "_")
|
|
80
|
+
if norm in ("numbered", "numbered_list", "number"):
|
|
81
|
+
return NumberedListFormatter()
|
|
82
|
+
if norm in ("xml", "xml_blocks", "xml_block"):
|
|
83
|
+
return XMLBlocksFormatter()
|
|
84
|
+
if norm in ("plain", "concatenation", "plain_concatenation", "concat"):
|
|
85
|
+
return PlainConcatenationFormatter()
|
|
86
|
+
|
|
87
|
+
raise ValueError(
|
|
88
|
+
f"Unknown context formatter strategy '{strategy}'. "
|
|
89
|
+
f"Supported strategies: 'numbered', 'xml', 'concatenation'."
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def numbered(
|
|
94
|
+
cls,
|
|
95
|
+
separator: str = "\n\n",
|
|
96
|
+
include_metadata: bool = False,
|
|
97
|
+
) -> NumberedListFormatter:
|
|
98
|
+
"""Create a numbered list context formatter."""
|
|
99
|
+
return NumberedListFormatter(separator=separator, include_metadata=include_metadata)
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def xml(
|
|
103
|
+
cls,
|
|
104
|
+
root_tag: str = "context",
|
|
105
|
+
document_tag: str = "document",
|
|
106
|
+
include_attributes: bool = True,
|
|
107
|
+
escape_content: bool = True,
|
|
108
|
+
) -> XMLBlocksFormatter:
|
|
109
|
+
"""Create an XML blocks context formatter."""
|
|
110
|
+
return XMLBlocksFormatter(
|
|
111
|
+
root_tag=root_tag,
|
|
112
|
+
document_tag=document_tag,
|
|
113
|
+
include_attributes=include_attributes,
|
|
114
|
+
escape_content=escape_content,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
@classmethod
|
|
118
|
+
def concatenation(
|
|
119
|
+
cls,
|
|
120
|
+
separator: str = "\n\n",
|
|
121
|
+
prefix: str = "",
|
|
122
|
+
suffix: str = "",
|
|
123
|
+
) -> PlainConcatenationFormatter:
|
|
124
|
+
"""Create a plain concatenation context formatter."""
|
|
125
|
+
return PlainConcatenationFormatter(separator=separator, prefix=prefix, suffix=suffix)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class NumberedListFormatter(ContextFormatter):
|
|
129
|
+
"""
|
|
130
|
+
Formats retrieved documents as a numbered list:
|
|
131
|
+
[1] First document content
|
|
132
|
+
[2] Second document content
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
def __init__(
|
|
136
|
+
self,
|
|
137
|
+
separator: str = "\n\n",
|
|
138
|
+
include_metadata: bool = False,
|
|
139
|
+
) -> None:
|
|
140
|
+
self.separator = separator
|
|
141
|
+
self.include_metadata = include_metadata
|
|
142
|
+
|
|
143
|
+
def format(
|
|
144
|
+
self,
|
|
145
|
+
documents: Sequence[SearchResult | str | dict[str, Any]],
|
|
146
|
+
) -> str:
|
|
147
|
+
if not documents:
|
|
148
|
+
return ""
|
|
149
|
+
|
|
150
|
+
blocks: list[str] = []
|
|
151
|
+
for idx, doc in enumerate(documents, start=1):
|
|
152
|
+
content, doc_id, metadata, _ = _extract_doc_content(doc, idx)
|
|
153
|
+
safe_content = escape_context_boundaries(content)
|
|
154
|
+
header = f"[{idx}]"
|
|
155
|
+
if self.include_metadata:
|
|
156
|
+
meta_parts = [f"id: {escape_context_boundaries(str(doc_id))}"]
|
|
157
|
+
source = metadata.get("source") or metadata.get("title")
|
|
158
|
+
if source:
|
|
159
|
+
meta_parts.append(
|
|
160
|
+
f"source: {escape_context_boundaries(str(source))}"
|
|
161
|
+
)
|
|
162
|
+
header += f" ({', '.join(meta_parts)})"
|
|
163
|
+
|
|
164
|
+
blocks.append(f"{header} {safe_content}")
|
|
165
|
+
|
|
166
|
+
return self.separator.join(blocks)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class XMLBlocksFormatter(ContextFormatter):
|
|
170
|
+
"""
|
|
171
|
+
Formats retrieved documents into structured XML blocks:
|
|
172
|
+
<context>
|
|
173
|
+
<document index="1" id="doc_1">
|
|
174
|
+
Content of document 1
|
|
175
|
+
</document>
|
|
176
|
+
</context>
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
def __init__(
|
|
180
|
+
self,
|
|
181
|
+
root_tag: str = "context",
|
|
182
|
+
document_tag: str = "document",
|
|
183
|
+
include_attributes: bool = True,
|
|
184
|
+
escape_content: bool = True,
|
|
185
|
+
) -> None:
|
|
186
|
+
if not isinstance(root_tag, str) or not _XML_TAG_NAME_RE.match(root_tag):
|
|
187
|
+
raise ValueError(
|
|
188
|
+
f"Invalid XML root_tag {root_tag!r}; expected match for {_XML_TAG_NAME_RE.pattern}"
|
|
189
|
+
)
|
|
190
|
+
if not isinstance(document_tag, str) or not _XML_TAG_NAME_RE.match(document_tag):
|
|
191
|
+
raise ValueError(
|
|
192
|
+
f"Invalid XML document_tag {document_tag!r}; expected match for {_XML_TAG_NAME_RE.pattern}"
|
|
193
|
+
)
|
|
194
|
+
self.root_tag = root_tag
|
|
195
|
+
self.document_tag = document_tag
|
|
196
|
+
self.include_attributes = include_attributes
|
|
197
|
+
self.escape_content = escape_content
|
|
198
|
+
|
|
199
|
+
def format(
|
|
200
|
+
self,
|
|
201
|
+
documents: Sequence[SearchResult | str | dict[str, Any]],
|
|
202
|
+
) -> str:
|
|
203
|
+
if not documents:
|
|
204
|
+
return f"<{self.root_tag}></{self.root_tag}>"
|
|
205
|
+
|
|
206
|
+
lines: list[str] = [f"<{self.root_tag}>"]
|
|
207
|
+
for idx, doc in enumerate(documents, start=1):
|
|
208
|
+
content, doc_id, metadata, _score = _extract_doc_content(doc, idx)
|
|
209
|
+
attrs = ""
|
|
210
|
+
if self.include_attributes:
|
|
211
|
+
safe_id = html.escape(str(doc_id), quote=True)
|
|
212
|
+
attrs = f' index="{idx}" id="{safe_id}"'
|
|
213
|
+
source = metadata.get("source") or metadata.get("title")
|
|
214
|
+
if source:
|
|
215
|
+
safe_source = html.escape(str(source), quote=True)
|
|
216
|
+
attrs += f' source="{safe_source}"'
|
|
217
|
+
|
|
218
|
+
lines.append(f" <{self.document_tag}{attrs}>")
|
|
219
|
+
# Indent each line of content (preserving trailing newlines) and escape untrusted markup
|
|
220
|
+
safe_content = (
|
|
221
|
+
content if self.escape_content else escape_context_boundaries(content)
|
|
222
|
+
)
|
|
223
|
+
for content_line in safe_content.split("\n"):
|
|
224
|
+
formatted_line = (
|
|
225
|
+
html.escape(escape_context_boundaries(content_line))
|
|
226
|
+
if self.escape_content
|
|
227
|
+
else content_line
|
|
228
|
+
)
|
|
229
|
+
lines.append(f" {formatted_line}")
|
|
230
|
+
lines.append(f" </{self.document_tag}>")
|
|
231
|
+
|
|
232
|
+
lines.append(f"</{self.root_tag}>")
|
|
233
|
+
return "\n".join(lines)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class PlainConcatenationFormatter(ContextFormatter):
|
|
237
|
+
"""
|
|
238
|
+
Formats retrieved documents by joining their raw text with a separator.
|
|
239
|
+
"""
|
|
240
|
+
|
|
241
|
+
def __init__(
|
|
242
|
+
self,
|
|
243
|
+
separator: str = "\n\n",
|
|
244
|
+
prefix: str = "",
|
|
245
|
+
suffix: str = "",
|
|
246
|
+
) -> None:
|
|
247
|
+
self.separator = separator
|
|
248
|
+
self.prefix = prefix
|
|
249
|
+
self.suffix = suffix
|
|
250
|
+
|
|
251
|
+
def format(
|
|
252
|
+
self,
|
|
253
|
+
documents: Sequence[SearchResult | str | dict[str, Any]],
|
|
254
|
+
) -> str:
|
|
255
|
+
if not documents:
|
|
256
|
+
return ""
|
|
257
|
+
|
|
258
|
+
chunks = [
|
|
259
|
+
escape_context_boundaries(_extract_doc_content(doc, idx)[0])
|
|
260
|
+
for idx, doc in enumerate(documents, start=1)
|
|
261
|
+
]
|
|
262
|
+
joined = self.separator.join(chunks)
|
|
263
|
+
return f"{self.prefix}{joined}{self.suffix}"
|