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