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
swiftpy/ai/client.py
ADDED
|
@@ -0,0 +1,1011 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import copy
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import AsyncGenerator, AsyncIterator, Coroutine, Sequence
|
|
8
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Self
|
|
9
|
+
|
|
10
|
+
from swiftpy.ai._fake import AIFake, FixtureType
|
|
11
|
+
from swiftpy.ai.embeddings import EmbeddingsClient
|
|
12
|
+
from swiftpy.ai.exceptions import (
|
|
13
|
+
AIConfigurationError,
|
|
14
|
+
AIModuleDisabledError,
|
|
15
|
+
)
|
|
16
|
+
from swiftpy.ai.middleware import AIMiddlewarePipeline
|
|
17
|
+
from swiftpy.ai.observability.observer import (
|
|
18
|
+
ObservationContext,
|
|
19
|
+
compute_prompt_hash,
|
|
20
|
+
get_observability,
|
|
21
|
+
)
|
|
22
|
+
from swiftpy.ai.pool import AIHttpPool
|
|
23
|
+
from swiftpy.ai.prompts.loader import aload_prompt, load_prompt
|
|
24
|
+
from swiftpy.ai.providers.anthropic_provider import AnthropicProvider
|
|
25
|
+
from swiftpy.ai.providers.base import (
|
|
26
|
+
INTERNAL_ATTRIBUTION_KEYS,
|
|
27
|
+
LLMRequest,
|
|
28
|
+
LLMResponse,
|
|
29
|
+
Message,
|
|
30
|
+
ProviderInterface,
|
|
31
|
+
TokenUsage,
|
|
32
|
+
)
|
|
33
|
+
from swiftpy.ai.providers.openai_provider import OpenAIProvider
|
|
34
|
+
from swiftpy.ai.tools.base_tool import BaseTool
|
|
35
|
+
from swiftpy.ai.tools.tool_loop import ToolLoop
|
|
36
|
+
from swiftpy.ai.vectorstore.base import VectorStore
|
|
37
|
+
from swiftpy.core.config import Config
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from swiftpy.ai.conversation import Conversation
|
|
41
|
+
|
|
42
|
+
_DEFAULT_PROVIDER_MODELS: dict[str, str] = {
|
|
43
|
+
"openai": "gpt-4o",
|
|
44
|
+
"anthropic": "claude-3-5-sonnet-20241022",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _consume_task_exception(task: asyncio.Task[Any]) -> None:
|
|
49
|
+
"""Retrieve exceptions from background tasks so asyncio never warns on GC."""
|
|
50
|
+
if not task.cancelled():
|
|
51
|
+
task.exception()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _estimate_token_count(text: str) -> int:
|
|
55
|
+
"""Estimate token count for text, returning 0 for empty content."""
|
|
56
|
+
if not text:
|
|
57
|
+
return 0
|
|
58
|
+
return max(1, len(text) // 4)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _is_real_int(value: Any) -> bool:
|
|
62
|
+
"""Return True if value is an int and not a bool."""
|
|
63
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _is_real_number(value: Any) -> bool:
|
|
67
|
+
"""Return True if value is an int/float and not a bool."""
|
|
68
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def _next_stream_chunk(gen_iter: AsyncIterator[str]) -> str:
|
|
72
|
+
"""Await the next chunk from an async stream iterator as a coroutine."""
|
|
73
|
+
return await gen_iter.__anext__()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def _await_or_abort(
|
|
77
|
+
waiter_coro: Coroutine[Any, Any, Any],
|
|
78
|
+
abort_event: asyncio.Event,
|
|
79
|
+
) -> tuple[bool, Any]:
|
|
80
|
+
"""Await waiter_coro unless abort_event fires first; returns (aborted, result)."""
|
|
81
|
+
wait_task = asyncio.create_task(waiter_coro)
|
|
82
|
+
wait_task.add_done_callback(_consume_task_exception)
|
|
83
|
+
abort_task = asyncio.create_task(abort_event.wait())
|
|
84
|
+
abort_task.add_done_callback(_consume_task_exception)
|
|
85
|
+
try:
|
|
86
|
+
await asyncio.wait(
|
|
87
|
+
{wait_task, abort_task},
|
|
88
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
89
|
+
)
|
|
90
|
+
finally:
|
|
91
|
+
if not abort_task.done():
|
|
92
|
+
abort_task.cancel()
|
|
93
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
94
|
+
await abort_task
|
|
95
|
+
if abort_event.is_set():
|
|
96
|
+
if not wait_task.done():
|
|
97
|
+
wait_task.cancel()
|
|
98
|
+
with contextlib.suppress(asyncio.CancelledError, StopAsyncIteration, Exception):
|
|
99
|
+
await wait_task
|
|
100
|
+
return True, None
|
|
101
|
+
return False, await wait_task
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class AIClient:
|
|
105
|
+
"""
|
|
106
|
+
Fluent builder client for LLM completions.
|
|
107
|
+
|
|
108
|
+
Provides a chained builder interface:
|
|
109
|
+
response = await (
|
|
110
|
+
AIClient()
|
|
111
|
+
.system("You are a helpful coding assistant.")
|
|
112
|
+
.user("Explain Python async/await in two sentences.")
|
|
113
|
+
.temperature(0.7)
|
|
114
|
+
.max_tokens(150)
|
|
115
|
+
.send()
|
|
116
|
+
)
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
_registry: ClassVar[dict[str, type[ProviderInterface] | ProviderInterface]] = {
|
|
120
|
+
"openai": OpenAIProvider,
|
|
121
|
+
"anthropic": AnthropicProvider,
|
|
122
|
+
}
|
|
123
|
+
_global_middlewares: ClassVar[list[Any]] = []
|
|
124
|
+
|
|
125
|
+
def __init__(
|
|
126
|
+
self,
|
|
127
|
+
config: Config | None = None,
|
|
128
|
+
http_pool: AIHttpPool | None = None,
|
|
129
|
+
default_provider: str | None = None,
|
|
130
|
+
) -> None:
|
|
131
|
+
if config is None:
|
|
132
|
+
try:
|
|
133
|
+
self._config = Config.get_instance()
|
|
134
|
+
except RuntimeError:
|
|
135
|
+
self._config = Config()
|
|
136
|
+
else:
|
|
137
|
+
self._config = config
|
|
138
|
+
|
|
139
|
+
self._http_pool = http_pool or AIHttpPool.get_instance()
|
|
140
|
+
self._provider_name: str | None = default_provider
|
|
141
|
+
|
|
142
|
+
self._messages: list[Message] = []
|
|
143
|
+
self._temperature: float | None = None
|
|
144
|
+
self._max_tokens: int | None = None
|
|
145
|
+
self._top_p: float | None = None
|
|
146
|
+
self._stop: list[str] | None = None
|
|
147
|
+
self._model: str | None = None
|
|
148
|
+
self._extra: dict[str, Any] = {}
|
|
149
|
+
self._metadata: dict[str, Any] = {}
|
|
150
|
+
self._attached_conversation: Conversation | None = None
|
|
151
|
+
self._pending_conversation_messages: list[Message] = []
|
|
152
|
+
self._tools: list[BaseTool] = []
|
|
153
|
+
self._auto_execute_tools: bool = False
|
|
154
|
+
self._max_iterations: int = 10
|
|
155
|
+
self._max_tool_calls: int | None = None
|
|
156
|
+
self._allowed_tools: Sequence[str] | None = None
|
|
157
|
+
self._tool_timeout: float | None = None
|
|
158
|
+
self._max_total_tokens: int | None = None
|
|
159
|
+
self._max_cost: float | None = None
|
|
160
|
+
self._suppress_tools_in_request: bool = False
|
|
161
|
+
self._is_executing_tool_loop: bool = False
|
|
162
|
+
self._last_tool_loop: ToolLoop | None = None
|
|
163
|
+
self._middlewares: list[Any] = []
|
|
164
|
+
|
|
165
|
+
def clone(
|
|
166
|
+
self,
|
|
167
|
+
*,
|
|
168
|
+
include_messages: bool = False,
|
|
169
|
+
include_conversation: bool = True,
|
|
170
|
+
) -> AIClient:
|
|
171
|
+
"""
|
|
172
|
+
Return a shallow-isolated copy of this client preserving provider,
|
|
173
|
+
options, middlewares, and tools without mutating ``AIClient._registry``.
|
|
174
|
+
"""
|
|
175
|
+
cloned = AIClient(
|
|
176
|
+
config=self._config,
|
|
177
|
+
http_pool=self._http_pool,
|
|
178
|
+
default_provider=self._provider_name,
|
|
179
|
+
)
|
|
180
|
+
cloned._model = self._model
|
|
181
|
+
cloned._temperature = self._temperature
|
|
182
|
+
cloned._max_tokens = self._max_tokens
|
|
183
|
+
cloned._top_p = self._top_p
|
|
184
|
+
cloned._stop = list(self._stop) if self._stop is not None else None
|
|
185
|
+
cloned._extra = copy.deepcopy(self._extra)
|
|
186
|
+
cloned._metadata = copy.deepcopy(self._metadata)
|
|
187
|
+
cloned._tools = list(self._tools)
|
|
188
|
+
cloned._auto_execute_tools = self._auto_execute_tools
|
|
189
|
+
cloned._max_iterations = self._max_iterations
|
|
190
|
+
cloned._max_tool_calls = self._max_tool_calls
|
|
191
|
+
cloned._allowed_tools = (
|
|
192
|
+
tuple(self._allowed_tools) if self._allowed_tools is not None else None
|
|
193
|
+
)
|
|
194
|
+
cloned._tool_timeout = self._tool_timeout
|
|
195
|
+
cloned._max_total_tokens = self._max_total_tokens
|
|
196
|
+
cloned._max_cost = self._max_cost
|
|
197
|
+
cloned._middlewares = list(self._middlewares)
|
|
198
|
+
if include_messages:
|
|
199
|
+
cloned._messages = copy.deepcopy(self._messages)
|
|
200
|
+
if include_conversation:
|
|
201
|
+
cloned._attached_conversation = self._attached_conversation
|
|
202
|
+
cloned._pending_conversation_messages = copy.deepcopy(
|
|
203
|
+
self._pending_conversation_messages
|
|
204
|
+
)
|
|
205
|
+
return cloned
|
|
206
|
+
|
|
207
|
+
@classmethod
|
|
208
|
+
def register_provider(
|
|
209
|
+
cls,
|
|
210
|
+
name: str,
|
|
211
|
+
provider_cls: type[ProviderInterface] | ProviderInterface,
|
|
212
|
+
) -> None:
|
|
213
|
+
"""Register a custom provider adapter class or instance."""
|
|
214
|
+
cls._registry[name.lower()] = provider_cls
|
|
215
|
+
|
|
216
|
+
@classmethod
|
|
217
|
+
def use_global_middleware(cls, middleware: Any) -> None:
|
|
218
|
+
"""Register global middleware applied to all AIClient instances."""
|
|
219
|
+
cls._global_middlewares.append(middleware)
|
|
220
|
+
|
|
221
|
+
@classmethod
|
|
222
|
+
def clear_global_middlewares(cls) -> None:
|
|
223
|
+
"""Clear all registered global middlewares."""
|
|
224
|
+
cls._global_middlewares.clear()
|
|
225
|
+
|
|
226
|
+
def use(self, middleware: Any) -> Self:
|
|
227
|
+
"""Attach a middleware layer to this client builder."""
|
|
228
|
+
self._middlewares.append(middleware)
|
|
229
|
+
return self
|
|
230
|
+
|
|
231
|
+
def middleware(self, middleware: Any) -> Self:
|
|
232
|
+
"""Alias for use()."""
|
|
233
|
+
return self.use(middleware)
|
|
234
|
+
|
|
235
|
+
def _append_message(self, msg: Message) -> None:
|
|
236
|
+
"""Append a message locally and buffer it if a Conversation is attached."""
|
|
237
|
+
self._messages.append(msg)
|
|
238
|
+
if self._attached_conversation is not None:
|
|
239
|
+
self._pending_conversation_messages.append(msg)
|
|
240
|
+
|
|
241
|
+
def _commit_conversation_turns(self, assistant_content: str | None = None) -> None:
|
|
242
|
+
"""Persist buffered turn messages and optional assistant reply to the attached Conversation."""
|
|
243
|
+
if self._attached_conversation is None:
|
|
244
|
+
return
|
|
245
|
+
while self._pending_conversation_messages:
|
|
246
|
+
self._attached_conversation.add_message(
|
|
247
|
+
self._pending_conversation_messages.pop(0)
|
|
248
|
+
)
|
|
249
|
+
if assistant_content:
|
|
250
|
+
self._attached_conversation.add_assistant(assistant_content)
|
|
251
|
+
|
|
252
|
+
def prompt(self, name: str, /, role: str = "user", **variables: Any) -> Self:
|
|
253
|
+
"""
|
|
254
|
+
Load a prompt template from disk, interpolate variables, and append as message.
|
|
255
|
+
"""
|
|
256
|
+
rendered = load_prompt(name, **variables)
|
|
257
|
+
self._append_message(Message(role=role, content=rendered))
|
|
258
|
+
return self
|
|
259
|
+
|
|
260
|
+
async def aprompt(self, name: str, /, role: str = "user", **variables: Any) -> Self:
|
|
261
|
+
"""
|
|
262
|
+
Asynchronously load a prompt template off the event loop, interpolate variables,
|
|
263
|
+
and append as message.
|
|
264
|
+
"""
|
|
265
|
+
rendered = await aload_prompt(name, **variables)
|
|
266
|
+
self._append_message(Message(role=role, content=rendered))
|
|
267
|
+
return self
|
|
268
|
+
|
|
269
|
+
def system(self, content: str) -> Self:
|
|
270
|
+
"""Add a system message prompt."""
|
|
271
|
+
self._append_message(Message(role="system", content=content))
|
|
272
|
+
return self
|
|
273
|
+
|
|
274
|
+
def user(self, content: str) -> Self:
|
|
275
|
+
"""Add a user message prompt."""
|
|
276
|
+
self._append_message(Message(role="user", content=content))
|
|
277
|
+
return self
|
|
278
|
+
|
|
279
|
+
def assistant(self, content: str) -> Self:
|
|
280
|
+
"""Add an assistant message to conversation history."""
|
|
281
|
+
self._messages.append(Message(role="assistant", content=content))
|
|
282
|
+
return self
|
|
283
|
+
|
|
284
|
+
def conversation(self, conv: Conversation) -> Self:
|
|
285
|
+
"""
|
|
286
|
+
Attach a Conversation container to this client.
|
|
287
|
+
|
|
288
|
+
Pre-loads existing messages and automatically records new dialogue turns
|
|
289
|
+
(user prompt upon send/stream, and assistant reply upon successful completion).
|
|
290
|
+
"""
|
|
291
|
+
self._attached_conversation = conv
|
|
292
|
+
for msg in conv.messages:
|
|
293
|
+
self._messages.append(msg)
|
|
294
|
+
return self
|
|
295
|
+
|
|
296
|
+
def history(self, messages: Sequence[Message | dict[str, str]]) -> Self:
|
|
297
|
+
"""
|
|
298
|
+
Append conversation history to the existing messages in order.
|
|
299
|
+
|
|
300
|
+
Accepts Message instances or dictionaries containing 'role' and 'content' keys.
|
|
301
|
+
Empty sequences are accepted as a no-op without clearing earlier messages.
|
|
302
|
+
"""
|
|
303
|
+
if not isinstance(messages, Sequence):
|
|
304
|
+
raise TypeError(
|
|
305
|
+
f"Expected a sequence for history, got {type(messages).__name__}"
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
for msg in messages:
|
|
309
|
+
if isinstance(msg, Message):
|
|
310
|
+
self._messages.append(msg)
|
|
311
|
+
elif isinstance(msg, dict):
|
|
312
|
+
role = str(msg.get("role", "user"))
|
|
313
|
+
content = str(msg.get("content", ""))
|
|
314
|
+
name = msg.get("name")
|
|
315
|
+
self._messages.append(Message(role=role, content=content, name=name))
|
|
316
|
+
else:
|
|
317
|
+
raise TypeError(
|
|
318
|
+
f"Expected Message or dict in history, got {type(msg).__name__}"
|
|
319
|
+
)
|
|
320
|
+
return self
|
|
321
|
+
|
|
322
|
+
def temperature(self, temp: float) -> Self:
|
|
323
|
+
"""
|
|
324
|
+
Set sampling temperature.
|
|
325
|
+
Must be between 0.0 and 2.0 inclusive.
|
|
326
|
+
"""
|
|
327
|
+
if not _is_real_number(temp) or not (0.0 <= float(temp) <= 2.0):
|
|
328
|
+
raise ValueError(f"Temperature must be between 0.0 and 2.0, got {temp}")
|
|
329
|
+
self._temperature = float(temp)
|
|
330
|
+
return self
|
|
331
|
+
|
|
332
|
+
def max_tokens(self, tokens: int) -> Self:
|
|
333
|
+
"""
|
|
334
|
+
Set maximum tokens to generate.
|
|
335
|
+
Must be a positive integer greater than 0.
|
|
336
|
+
"""
|
|
337
|
+
if not _is_real_int(tokens) or tokens <= 0:
|
|
338
|
+
raise ValueError(
|
|
339
|
+
f"max_tokens must be a positive integer greater than 0, got {tokens}"
|
|
340
|
+
)
|
|
341
|
+
self._max_tokens = tokens
|
|
342
|
+
return self
|
|
343
|
+
|
|
344
|
+
def top_p(self, p: float) -> Self:
|
|
345
|
+
"""
|
|
346
|
+
Set nucleus sampling top_p parameter.
|
|
347
|
+
Must be between 0.0 and 1.0 inclusive.
|
|
348
|
+
"""
|
|
349
|
+
if not _is_real_number(p) or not (0.0 <= float(p) <= 1.0):
|
|
350
|
+
raise ValueError(f"top_p must be between 0.0 and 1.0, got {p}")
|
|
351
|
+
self._top_p = float(p)
|
|
352
|
+
return self
|
|
353
|
+
|
|
354
|
+
def stop(self, sequences: list[str] | str) -> Self:
|
|
355
|
+
"""
|
|
356
|
+
Set stop sequence(s).
|
|
357
|
+
Must be a non-empty string or a sequence of non-empty strings.
|
|
358
|
+
"""
|
|
359
|
+
if isinstance(sequences, str):
|
|
360
|
+
if not sequences:
|
|
361
|
+
raise ValueError("Stop sequence string cannot be empty")
|
|
362
|
+
self._stop = [sequences]
|
|
363
|
+
elif isinstance(sequences, Sequence):
|
|
364
|
+
if not sequences:
|
|
365
|
+
raise ValueError("Stop sequence list cannot be empty")
|
|
366
|
+
parsed: list[str] = []
|
|
367
|
+
for item in sequences:
|
|
368
|
+
if not isinstance(item, str) or not item:
|
|
369
|
+
raise ValueError(
|
|
370
|
+
f"All stop sequence items must be non-empty strings, got {item!r}"
|
|
371
|
+
)
|
|
372
|
+
parsed.append(item)
|
|
373
|
+
self._stop = parsed
|
|
374
|
+
else:
|
|
375
|
+
raise TypeError(
|
|
376
|
+
f"Expected str or sequence of str for stop sequences, got {type(sequences).__name__}"
|
|
377
|
+
)
|
|
378
|
+
return self
|
|
379
|
+
|
|
380
|
+
def model(self, model_name: str) -> Self:
|
|
381
|
+
"""Explicitly override the model used for this request."""
|
|
382
|
+
if not isinstance(model_name, str) or not model_name.strip():
|
|
383
|
+
raise ValueError("Model name must be a non-empty string")
|
|
384
|
+
self._model = model_name.strip()
|
|
385
|
+
return self
|
|
386
|
+
|
|
387
|
+
def provider(self, provider_name: str) -> Self:
|
|
388
|
+
"""Explicitly override the provider adapter for this request."""
|
|
389
|
+
if not isinstance(provider_name, str) or not provider_name.strip():
|
|
390
|
+
raise ValueError("Provider name must be a non-empty string")
|
|
391
|
+
self._provider_name = provider_name.strip().lower()
|
|
392
|
+
return self
|
|
393
|
+
|
|
394
|
+
def metadata(self, **kwargs: Any) -> Self:
|
|
395
|
+
"""Attach internal metadata (attribution/tracing) isolated from upstream payloads."""
|
|
396
|
+
self._metadata.update(kwargs)
|
|
397
|
+
return self
|
|
398
|
+
|
|
399
|
+
def extra(self, **kwargs: Any) -> Self:
|
|
400
|
+
"""Pass additional provider-specific parameters."""
|
|
401
|
+
self._extra.update(kwargs)
|
|
402
|
+
return self
|
|
403
|
+
|
|
404
|
+
def tools(self, tools: Sequence[BaseTool | type[BaseTool]]) -> Self:
|
|
405
|
+
"""
|
|
406
|
+
Register tools available for LLM tool calling.
|
|
407
|
+
|
|
408
|
+
Accepts instances of BaseTool or BaseTool subclasses (which are instantiated).
|
|
409
|
+
"""
|
|
410
|
+
if not isinstance(tools, Sequence):
|
|
411
|
+
raise TypeError(f"Expected a sequence for tools, got {type(tools).__name__}")
|
|
412
|
+
|
|
413
|
+
resolved: list[BaseTool] = []
|
|
414
|
+
for t in tools:
|
|
415
|
+
if isinstance(t, type) and issubclass(t, BaseTool):
|
|
416
|
+
resolved.append(t())
|
|
417
|
+
elif isinstance(t, BaseTool):
|
|
418
|
+
resolved.append(t)
|
|
419
|
+
else:
|
|
420
|
+
raise TypeError(
|
|
421
|
+
f"Expected BaseTool instance or subclass, got {type(t).__name__}"
|
|
422
|
+
)
|
|
423
|
+
self._tools = resolved
|
|
424
|
+
return self
|
|
425
|
+
|
|
426
|
+
def auto_execute_tools(
|
|
427
|
+
self,
|
|
428
|
+
enable: bool = True,
|
|
429
|
+
max_iterations: int = 10,
|
|
430
|
+
max_tool_calls: int | None = None,
|
|
431
|
+
allowed_tools: Sequence[str] | None = None,
|
|
432
|
+
tool_timeout: float | None = None,
|
|
433
|
+
max_total_tokens: int | None = None,
|
|
434
|
+
max_cost: float | None = None,
|
|
435
|
+
) -> Self:
|
|
436
|
+
"""
|
|
437
|
+
Configure autonomous tool execution loop.
|
|
438
|
+
|
|
439
|
+
When enabled, calling .send() will automatically detect tool use responses from the
|
|
440
|
+
LLM, execute matching tool handlers concurrently, feed tool results back, and
|
|
441
|
+
repeat until a final text response is produced or configured limits are reached.
|
|
442
|
+
"""
|
|
443
|
+
if not _is_real_int(max_iterations) or max_iterations <= 0:
|
|
444
|
+
raise ValueError(f"max_iterations must be a positive integer, got {max_iterations}")
|
|
445
|
+
if max_tool_calls is not None and (
|
|
446
|
+
not _is_real_int(max_tool_calls) or max_tool_calls <= 0
|
|
447
|
+
):
|
|
448
|
+
raise ValueError(f"max_tool_calls must be a positive integer, got {max_tool_calls}")
|
|
449
|
+
if tool_timeout is not None and (
|
|
450
|
+
not _is_real_number(tool_timeout) or float(tool_timeout) <= 0
|
|
451
|
+
):
|
|
452
|
+
raise ValueError(f"tool_timeout must be a positive float, got {tool_timeout}")
|
|
453
|
+
if max_total_tokens is not None and (
|
|
454
|
+
not _is_real_int(max_total_tokens) or max_total_tokens <= 0
|
|
455
|
+
):
|
|
456
|
+
raise ValueError(
|
|
457
|
+
f"max_total_tokens must be a positive integer, got {max_total_tokens}"
|
|
458
|
+
)
|
|
459
|
+
if max_cost is not None and (
|
|
460
|
+
not _is_real_number(max_cost) or float(max_cost) <= 0
|
|
461
|
+
):
|
|
462
|
+
raise ValueError(f"max_cost must be a positive float, got {max_cost}")
|
|
463
|
+
|
|
464
|
+
self._auto_execute_tools = bool(enable)
|
|
465
|
+
self._max_iterations = max_iterations
|
|
466
|
+
self._max_tool_calls = max_tool_calls
|
|
467
|
+
self._allowed_tools = allowed_tools
|
|
468
|
+
self._tool_timeout = tool_timeout
|
|
469
|
+
self._max_total_tokens = max_total_tokens
|
|
470
|
+
self._max_cost = float(max_cost) if max_cost is not None else None
|
|
471
|
+
return self
|
|
472
|
+
|
|
473
|
+
@property
|
|
474
|
+
def registered_tools(self) -> list[BaseTool]:
|
|
475
|
+
"""Return a defensive copy of currently registered tools."""
|
|
476
|
+
return list(self._tools)
|
|
477
|
+
|
|
478
|
+
@property
|
|
479
|
+
def last_tool_loop(self) -> ToolLoop | None:
|
|
480
|
+
"""Return the ToolLoop instance from the most recent autonomous execution."""
|
|
481
|
+
return self._last_tool_loop
|
|
482
|
+
|
|
483
|
+
def _resolve_provider(self) -> ProviderInterface:
|
|
484
|
+
# Check if AI module is enabled in configuration
|
|
485
|
+
is_enabled = self._config.get("ai.enabled", True)
|
|
486
|
+
if not is_enabled:
|
|
487
|
+
raise AIModuleDisabledError(
|
|
488
|
+
"AI module is disabled in configuration (ai.enabled is false)."
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
provider_name = (
|
|
492
|
+
self._provider_name
|
|
493
|
+
or str(self._config.get("ai.default", "openai"))
|
|
494
|
+
).lower()
|
|
495
|
+
|
|
496
|
+
provider_cls = self._registry.get(provider_name)
|
|
497
|
+
if not provider_cls:
|
|
498
|
+
supported = ", ".join(sorted(self._registry.keys()))
|
|
499
|
+
raise AIConfigurationError(
|
|
500
|
+
f"Unknown LLM provider '{provider_name}'. Supported providers: {supported}"
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
raw_cfg = self._config.get(f"ai.providers.{provider_name}", {})
|
|
504
|
+
provider_cfg: dict[str, Any] = raw_cfg if isinstance(raw_cfg, dict) else {}
|
|
505
|
+
|
|
506
|
+
# Validate that an API key is configured for the chosen provider
|
|
507
|
+
api_key = str(provider_cfg.get("api_key", "")).strip()
|
|
508
|
+
if not api_key:
|
|
509
|
+
raise AIConfigurationError(
|
|
510
|
+
f"Missing API key for provider '{provider_name}'. "
|
|
511
|
+
f"Please configure 'ai.providers.{provider_name}.api_key' "
|
|
512
|
+
f"or set the appropriate environment variable."
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
if isinstance(provider_cls, type):
|
|
516
|
+
return provider_cls(config=provider_cfg, http_pool=self._http_pool)
|
|
517
|
+
return provider_cls
|
|
518
|
+
|
|
519
|
+
def _resolve_target_identity(self) -> tuple[str, str | None]:
|
|
520
|
+
"""Resolve provider and model names from builder state/config without API key checks."""
|
|
521
|
+
provider_name = (
|
|
522
|
+
self._provider_name
|
|
523
|
+
or str(self._config.get("ai.default", "openai"))
|
|
524
|
+
).lower()
|
|
525
|
+
|
|
526
|
+
model_name = self._model
|
|
527
|
+
if not model_name:
|
|
528
|
+
raw_cfg = self._config.get(f"ai.providers.{provider_name}", {})
|
|
529
|
+
if isinstance(raw_cfg, dict):
|
|
530
|
+
cfg_model = raw_cfg.get("model") or raw_cfg.get("default_model")
|
|
531
|
+
if isinstance(cfg_model, str) and cfg_model.strip():
|
|
532
|
+
model_name = cfg_model.strip()
|
|
533
|
+
|
|
534
|
+
if not model_name:
|
|
535
|
+
registered = self._registry.get(provider_name)
|
|
536
|
+
if registered is not None and not isinstance(registered, type):
|
|
537
|
+
model_name = registered.get_default_model()
|
|
538
|
+
else:
|
|
539
|
+
model_name = _DEFAULT_PROVIDER_MODELS.get(provider_name)
|
|
540
|
+
|
|
541
|
+
return provider_name, model_name
|
|
542
|
+
|
|
543
|
+
def _build_llm_request(self, *, include_tools: bool) -> tuple[str, str | None, LLMRequest]:
|
|
544
|
+
"""Construct an LLMRequest with attribution metadata isolated from wire extra."""
|
|
545
|
+
provider_name, model_name = self._resolve_target_identity()
|
|
546
|
+
metadata_dict: dict[str, Any] = dict(self._metadata)
|
|
547
|
+
wire_extra: dict[str, Any] = {}
|
|
548
|
+
for key, value in self._extra.items():
|
|
549
|
+
if key in INTERNAL_ATTRIBUTION_KEYS:
|
|
550
|
+
metadata_dict.setdefault(key, value)
|
|
551
|
+
else:
|
|
552
|
+
wire_extra[key] = value
|
|
553
|
+
metadata_dict.setdefault("provider", provider_name)
|
|
554
|
+
|
|
555
|
+
request_tools: list[dict[str, Any]] | None = None
|
|
556
|
+
if include_tools and self._tools:
|
|
557
|
+
allowed_set = set(self._allowed_tools) if self._allowed_tools is not None else None
|
|
558
|
+
filtered = [
|
|
559
|
+
t.to_dict()
|
|
560
|
+
for t in self._tools
|
|
561
|
+
if allowed_set is None or t.name in allowed_set
|
|
562
|
+
]
|
|
563
|
+
if filtered:
|
|
564
|
+
request_tools = filtered
|
|
565
|
+
|
|
566
|
+
request = LLMRequest(
|
|
567
|
+
messages=list(self._messages),
|
|
568
|
+
model=model_name,
|
|
569
|
+
temperature=self._temperature,
|
|
570
|
+
max_tokens=self._max_tokens,
|
|
571
|
+
top_p=self._top_p,
|
|
572
|
+
stop=self._stop,
|
|
573
|
+
tools=request_tools,
|
|
574
|
+
extra=wire_extra,
|
|
575
|
+
metadata=metadata_dict,
|
|
576
|
+
)
|
|
577
|
+
return provider_name, model_name, request
|
|
578
|
+
|
|
579
|
+
def _build_pipeline(self) -> AIMiddlewarePipeline:
|
|
580
|
+
"""Assemble global and client-scoped AI middleware in order."""
|
|
581
|
+
return AIMiddlewarePipeline(
|
|
582
|
+
list(self._global_middlewares) + list(self._middlewares)
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
async def send(self) -> LLMResponse:
|
|
586
|
+
"""
|
|
587
|
+
Dispatch the completion request to the active LLM provider.
|
|
588
|
+
"""
|
|
589
|
+
if not self._messages:
|
|
590
|
+
raise ValueError("Cannot send request: at least one message is required.")
|
|
591
|
+
|
|
592
|
+
# If auto_execute_tools is enabled and tools are registered, delegate to ToolLoop
|
|
593
|
+
if self._auto_execute_tools and self._tools and not self._is_executing_tool_loop:
|
|
594
|
+
self._is_executing_tool_loop = True
|
|
595
|
+
try:
|
|
596
|
+
loop = ToolLoop(
|
|
597
|
+
client=self,
|
|
598
|
+
tools=self._tools,
|
|
599
|
+
max_iterations=self._max_iterations,
|
|
600
|
+
max_tool_calls=self._max_tool_calls,
|
|
601
|
+
allowed_tools=self._allowed_tools,
|
|
602
|
+
tool_timeout=self._tool_timeout,
|
|
603
|
+
max_total_tokens=self._max_total_tokens,
|
|
604
|
+
max_cost=self._max_cost,
|
|
605
|
+
)
|
|
606
|
+
self._last_tool_loop = loop
|
|
607
|
+
final_response = await loop.run()
|
|
608
|
+
self._commit_conversation_turns(final_response.content)
|
|
609
|
+
return final_response
|
|
610
|
+
finally:
|
|
611
|
+
self._is_executing_tool_loop = False
|
|
612
|
+
|
|
613
|
+
_provider_name, _model_name, request = self._build_llm_request(
|
|
614
|
+
include_tools=not self._suppress_tools_in_request
|
|
615
|
+
)
|
|
616
|
+
pipeline = self._build_pipeline()
|
|
617
|
+
|
|
618
|
+
active_fake = AIFake.get_active()
|
|
619
|
+
if active_fake is not None:
|
|
620
|
+
async def fake_terminal(req: LLMRequest) -> LLMResponse:
|
|
621
|
+
return await active_fake.handle_send(self, req)
|
|
622
|
+
|
|
623
|
+
response = await pipeline.execute(request, fake_terminal)
|
|
624
|
+
if not self._is_executing_tool_loop:
|
|
625
|
+
self._commit_conversation_turns(response.content)
|
|
626
|
+
return response
|
|
627
|
+
|
|
628
|
+
observer = get_observability()
|
|
629
|
+
|
|
630
|
+
async def provider_terminal(req: LLMRequest) -> LLMResponse:
|
|
631
|
+
provider = self._resolve_provider()
|
|
632
|
+
obs_model = req.model or provider.get_default_model()
|
|
633
|
+
async with observer.observe_call(
|
|
634
|
+
model=obs_model,
|
|
635
|
+
provider=provider.name,
|
|
636
|
+
messages=req.messages,
|
|
637
|
+
metadata={**dict(req.extra), **dict(req.metadata)},
|
|
638
|
+
) as obs_ctx:
|
|
639
|
+
resp = await provider.send(req)
|
|
640
|
+
obs_ctx.set_response(resp)
|
|
641
|
+
return resp
|
|
642
|
+
|
|
643
|
+
response = await pipeline.execute(request, provider_terminal)
|
|
644
|
+
|
|
645
|
+
# Record user/assistant completion in attached conversation only after success
|
|
646
|
+
if not self._is_executing_tool_loop:
|
|
647
|
+
self._commit_conversation_turns(response.content)
|
|
648
|
+
|
|
649
|
+
return response
|
|
650
|
+
|
|
651
|
+
async def stream(self) -> AsyncIterator[str]:
|
|
652
|
+
"""
|
|
653
|
+
Dispatch the streaming completion request through middleware to the active provider.
|
|
654
|
+
|
|
655
|
+
Yields string token chunks incrementally as they arrive over SSE.
|
|
656
|
+
If an attached Conversation is present, persists buffered prompt turns once
|
|
657
|
+
streaming begins cleanly and appends the assistant reply only on completion.
|
|
658
|
+
"""
|
|
659
|
+
if not self._messages:
|
|
660
|
+
raise ValueError("Cannot stream request: at least one message is required.")
|
|
661
|
+
|
|
662
|
+
_provider_name, model_name, request = self._build_llm_request(include_tools=False)
|
|
663
|
+
pipeline = self._build_pipeline()
|
|
664
|
+
active_fake = AIFake.get_active()
|
|
665
|
+
|
|
666
|
+
done_sentinel = object()
|
|
667
|
+
chunk_queue: asyncio.Queue[object] = asyncio.Queue()
|
|
668
|
+
chunk_consumed = asyncio.Event()
|
|
669
|
+
abort_stream = asyncio.Event()
|
|
670
|
+
terminal_started = False
|
|
671
|
+
stream_completed = False
|
|
672
|
+
accumulated_chunks: list[str] = []
|
|
673
|
+
|
|
674
|
+
async def _pump_chunks(
|
|
675
|
+
gen: AsyncIterator[str],
|
|
676
|
+
on_first_chunk: Any | None = None,
|
|
677
|
+
) -> bool:
|
|
678
|
+
nonlocal stream_completed
|
|
679
|
+
first = True
|
|
680
|
+
gen_iter = gen.__aiter__()
|
|
681
|
+
while not abort_stream.is_set():
|
|
682
|
+
try:
|
|
683
|
+
aborted, chunk = await _await_or_abort(
|
|
684
|
+
_next_stream_chunk(gen_iter),
|
|
685
|
+
abort_stream,
|
|
686
|
+
)
|
|
687
|
+
except StopAsyncIteration:
|
|
688
|
+
stream_completed = True
|
|
689
|
+
break
|
|
690
|
+
if aborted:
|
|
691
|
+
break
|
|
692
|
+
if first and on_first_chunk is not None:
|
|
693
|
+
on_first_chunk()
|
|
694
|
+
first = False
|
|
695
|
+
chunk_text = str(chunk)
|
|
696
|
+
accumulated_chunks.append(chunk_text)
|
|
697
|
+
chunk_consumed.clear()
|
|
698
|
+
chunk_queue.put_nowait(chunk_text)
|
|
699
|
+
aborted, _ = await _await_or_abort(chunk_consumed.wait(), abort_stream)
|
|
700
|
+
if aborted:
|
|
701
|
+
break
|
|
702
|
+
return stream_completed
|
|
703
|
+
|
|
704
|
+
async def stream_terminal(req: LLMRequest) -> LLMResponse:
|
|
705
|
+
nonlocal terminal_started
|
|
706
|
+
terminal_started = True
|
|
707
|
+
prompt_chars = sum(len(m.content or "") for m in req.messages)
|
|
708
|
+
input_tokens = 0 if prompt_chars == 0 else max(1, prompt_chars // 4)
|
|
709
|
+
|
|
710
|
+
if active_fake is not None:
|
|
711
|
+
gen = active_fake.handle_stream(self, req)
|
|
712
|
+
try:
|
|
713
|
+
await _pump_chunks(gen)
|
|
714
|
+
finally:
|
|
715
|
+
if not stream_completed and isinstance(gen, AsyncGenerator):
|
|
716
|
+
await gen.aclose()
|
|
717
|
+
full_text = "".join(accumulated_chunks)
|
|
718
|
+
output_tokens = _estimate_token_count(full_text)
|
|
719
|
+
return LLMResponse(
|
|
720
|
+
content=full_text,
|
|
721
|
+
model=req.model or model_name or "fake-model",
|
|
722
|
+
usage=TokenUsage(
|
|
723
|
+
prompt_tokens=input_tokens,
|
|
724
|
+
completion_tokens=output_tokens,
|
|
725
|
+
total_tokens=input_tokens + output_tokens,
|
|
726
|
+
),
|
|
727
|
+
finish_reason="stop" if stream_completed else "cancelled",
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
provider = self._resolve_provider()
|
|
731
|
+
observer = get_observability()
|
|
732
|
+
obs_model = req.model or provider.get_default_model()
|
|
733
|
+
prompt_hash = compute_prompt_hash(req.messages)
|
|
734
|
+
obs_ctx = ObservationContext(
|
|
735
|
+
model=obs_model,
|
|
736
|
+
provider=provider.name,
|
|
737
|
+
prompt_hash=prompt_hash,
|
|
738
|
+
metadata={"stream": True, **dict(req.extra), **dict(req.metadata)},
|
|
739
|
+
)
|
|
740
|
+
stream_start = time.perf_counter()
|
|
741
|
+
gen = provider.stream(req)
|
|
742
|
+
try:
|
|
743
|
+
await _pump_chunks(
|
|
744
|
+
gen,
|
|
745
|
+
on_first_chunk=lambda: obs_ctx.record_first_token(stream_start),
|
|
746
|
+
)
|
|
747
|
+
except (asyncio.CancelledError, GeneratorExit):
|
|
748
|
+
obs_ctx.status = "cancelled"
|
|
749
|
+
obs_ctx.error_message = "Stream consumer disconnected or cancelled."
|
|
750
|
+
raise
|
|
751
|
+
except Exception as exc:
|
|
752
|
+
obs_ctx.status = "error"
|
|
753
|
+
obs_ctx.error_message = str(exc)
|
|
754
|
+
raise
|
|
755
|
+
finally:
|
|
756
|
+
if not stream_completed and isinstance(gen, AsyncGenerator):
|
|
757
|
+
await gen.aclose()
|
|
758
|
+
latency_ms = (time.perf_counter() - stream_start) * 1000.0
|
|
759
|
+
obs_ctx.duration_ms = latency_ms
|
|
760
|
+
if not stream_completed and obs_ctx.status == "success":
|
|
761
|
+
obs_ctx.status = "cancelled"
|
|
762
|
+
full_text = "".join(accumulated_chunks)
|
|
763
|
+
output_tokens = _estimate_token_count(full_text)
|
|
764
|
+
obs_ctx.set_tokens(input_tokens, output_tokens)
|
|
765
|
+
await observer.record(
|
|
766
|
+
model=obs_ctx.model,
|
|
767
|
+
provider=obs_ctx.provider,
|
|
768
|
+
input_tokens=obs_ctx.input_tokens,
|
|
769
|
+
output_tokens=obs_ctx.output_tokens,
|
|
770
|
+
latency_ms=latency_ms,
|
|
771
|
+
prompt_hash=obs_ctx.prompt_hash,
|
|
772
|
+
cost=obs_ctx.cost,
|
|
773
|
+
status=obs_ctx.status,
|
|
774
|
+
error_message=obs_ctx.error_message,
|
|
775
|
+
metadata=obs_ctx.metadata,
|
|
776
|
+
ttft_ms=obs_ctx.ttft_ms,
|
|
777
|
+
duration_ms=obs_ctx.duration_ms,
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
return LLMResponse(
|
|
781
|
+
content=full_text,
|
|
782
|
+
model=obs_model,
|
|
783
|
+
usage=TokenUsage(
|
|
784
|
+
prompt_tokens=input_tokens,
|
|
785
|
+
completion_tokens=output_tokens,
|
|
786
|
+
total_tokens=input_tokens + output_tokens,
|
|
787
|
+
),
|
|
788
|
+
finish_reason="stop" if stream_completed else "cancelled",
|
|
789
|
+
)
|
|
790
|
+
|
|
791
|
+
async def _run_pipeline() -> LLMResponse:
|
|
792
|
+
try:
|
|
793
|
+
return await pipeline.execute(request, stream_terminal)
|
|
794
|
+
finally:
|
|
795
|
+
chunk_queue.put_nowait(done_sentinel)
|
|
796
|
+
|
|
797
|
+
pipeline_task = asyncio.create_task(_run_pipeline())
|
|
798
|
+
pipeline_task.add_done_callback(_consume_task_exception)
|
|
799
|
+
|
|
800
|
+
try:
|
|
801
|
+
while True:
|
|
802
|
+
item = await chunk_queue.get()
|
|
803
|
+
if item is done_sentinel:
|
|
804
|
+
break
|
|
805
|
+
chunk_str = str(item)
|
|
806
|
+
chunk_consumed.set()
|
|
807
|
+
self._commit_conversation_turns(None)
|
|
808
|
+
yield chunk_str
|
|
809
|
+
|
|
810
|
+
pipeline_response = await pipeline_task
|
|
811
|
+
if not terminal_started:
|
|
812
|
+
self._commit_conversation_turns(None)
|
|
813
|
+
if pipeline_response.content:
|
|
814
|
+
yield pipeline_response.content
|
|
815
|
+
self._commit_conversation_turns(pipeline_response.content)
|
|
816
|
+
else:
|
|
817
|
+
self._commit_conversation_turns("".join(accumulated_chunks))
|
|
818
|
+
except (asyncio.CancelledError, GeneratorExit):
|
|
819
|
+
abort_stream.set()
|
|
820
|
+
if not pipeline_task.done():
|
|
821
|
+
with contextlib.suppress(Exception, asyncio.CancelledError):
|
|
822
|
+
await pipeline_task
|
|
823
|
+
raise
|
|
824
|
+
finally:
|
|
825
|
+
abort_stream.set()
|
|
826
|
+
if not pipeline_task.done():
|
|
827
|
+
with contextlib.suppress(Exception, asyncio.CancelledError):
|
|
828
|
+
await pipeline_task
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
class AI:
|
|
832
|
+
"""
|
|
833
|
+
High-level ergonomic facade for the SwiftPY AI native module.
|
|
834
|
+
|
|
835
|
+
Target usage:
|
|
836
|
+
@app.post("/chat/stream")
|
|
837
|
+
async def chat_stream(body: ChatRequest) -> Response:
|
|
838
|
+
async def tokens():
|
|
839
|
+
async for chunk in (
|
|
840
|
+
AI.chat("claude-sonnet-4-6")
|
|
841
|
+
.system("You are a helpful assistant.")
|
|
842
|
+
.user(body.message)
|
|
843
|
+
.stream()
|
|
844
|
+
):
|
|
845
|
+
yield chunk
|
|
846
|
+
return stream(tokens())
|
|
847
|
+
"""
|
|
848
|
+
|
|
849
|
+
@classmethod
|
|
850
|
+
def fake(cls, fixtures: FixtureType | None = None) -> AIFake:
|
|
851
|
+
"""
|
|
852
|
+
Activate an AIFake test double that intercepts all LLM calls across the application.
|
|
853
|
+
Zero real external API calls are made while active.
|
|
854
|
+
"""
|
|
855
|
+
return AIFake(fixtures).activate()
|
|
856
|
+
|
|
857
|
+
@classmethod
|
|
858
|
+
def restore(cls) -> None:
|
|
859
|
+
"""Deactivate active test fake and restore normal provider execution."""
|
|
860
|
+
active = AIFake.get_active()
|
|
861
|
+
if active is not None:
|
|
862
|
+
active.restore()
|
|
863
|
+
|
|
864
|
+
@classmethod
|
|
865
|
+
def add_middleware(cls, middleware: Any) -> None:
|
|
866
|
+
"""Register application-wide AI middleware."""
|
|
867
|
+
AIClient.use_global_middleware(middleware)
|
|
868
|
+
|
|
869
|
+
@classmethod
|
|
870
|
+
def prompt(
|
|
871
|
+
cls,
|
|
872
|
+
name: str,
|
|
873
|
+
/,
|
|
874
|
+
model: str | None = None,
|
|
875
|
+
provider: str | None = None,
|
|
876
|
+
**variables: Any,
|
|
877
|
+
) -> AIClient:
|
|
878
|
+
"""Start a fluent AIClient initialized with the specified prompt template."""
|
|
879
|
+
client = cls.chat(model=model, provider=provider)
|
|
880
|
+
client.prompt(name, **variables)
|
|
881
|
+
return client
|
|
882
|
+
|
|
883
|
+
@classmethod
|
|
884
|
+
async def aprompt(
|
|
885
|
+
cls,
|
|
886
|
+
name: str,
|
|
887
|
+
/,
|
|
888
|
+
model: str | None = None,
|
|
889
|
+
provider: str | None = None,
|
|
890
|
+
**variables: Any,
|
|
891
|
+
) -> AIClient:
|
|
892
|
+
"""Start a fluent AIClient asynchronously initialized with the specified prompt template."""
|
|
893
|
+
client = cls.chat(model=model, provider=provider)
|
|
894
|
+
await client.aprompt(name, **variables)
|
|
895
|
+
return client
|
|
896
|
+
|
|
897
|
+
@classmethod
|
|
898
|
+
def chat(
|
|
899
|
+
cls,
|
|
900
|
+
model: str | None = None,
|
|
901
|
+
provider: str | None = None,
|
|
902
|
+
config: Config | None = None,
|
|
903
|
+
http_pool: AIHttpPool | None = None,
|
|
904
|
+
) -> AIClient:
|
|
905
|
+
"""Create a fluent AIClient, automatically inferring provider from model when possible."""
|
|
906
|
+
client = AIClient(config=config, http_pool=http_pool)
|
|
907
|
+
if model:
|
|
908
|
+
client.model(model)
|
|
909
|
+
if not provider:
|
|
910
|
+
lower_model = model.lower()
|
|
911
|
+
if "claude" in lower_model:
|
|
912
|
+
client.provider("anthropic")
|
|
913
|
+
elif any(p in lower_model for p in ("gpt", "o1", "o3", "davinci", "text-")):
|
|
914
|
+
client.provider("openai")
|
|
915
|
+
if provider:
|
|
916
|
+
client.provider(provider)
|
|
917
|
+
return client
|
|
918
|
+
|
|
919
|
+
@classmethod
|
|
920
|
+
def client(
|
|
921
|
+
cls,
|
|
922
|
+
config: Config | None = None,
|
|
923
|
+
http_pool: AIHttpPool | None = None,
|
|
924
|
+
default_provider: str | None = None,
|
|
925
|
+
) -> AIClient:
|
|
926
|
+
"""Return a new AIClient builder."""
|
|
927
|
+
return AIClient(
|
|
928
|
+
config=config,
|
|
929
|
+
http_pool=http_pool,
|
|
930
|
+
default_provider=default_provider,
|
|
931
|
+
)
|
|
932
|
+
|
|
933
|
+
@classmethod
|
|
934
|
+
async def embed(
|
|
935
|
+
cls,
|
|
936
|
+
text: str,
|
|
937
|
+
model: str | None = None,
|
|
938
|
+
provider: str | None = None,
|
|
939
|
+
config: Config | None = None,
|
|
940
|
+
http_pool: AIHttpPool | None = None,
|
|
941
|
+
) -> list[float]:
|
|
942
|
+
"""Generate an embedding vector for a single text."""
|
|
943
|
+
client = EmbeddingsClient(config=config, http_pool=http_pool)
|
|
944
|
+
return await client.embed(text, model=model, provider=provider)
|
|
945
|
+
|
|
946
|
+
@classmethod
|
|
947
|
+
async def embed_batch(
|
|
948
|
+
cls,
|
|
949
|
+
texts: Sequence[str],
|
|
950
|
+
model: str | None = None,
|
|
951
|
+
provider: str | None = None,
|
|
952
|
+
chunk_size: int = 100,
|
|
953
|
+
concurrency: int = 5,
|
|
954
|
+
config: Config | None = None,
|
|
955
|
+
http_pool: AIHttpPool | None = None,
|
|
956
|
+
) -> list[list[float]]:
|
|
957
|
+
"""
|
|
958
|
+
Embed a collection of texts with automatic batch chunking and concurrency.
|
|
959
|
+
"""
|
|
960
|
+
client = EmbeddingsClient(config=config, http_pool=http_pool)
|
|
961
|
+
return await client.embed_batch(
|
|
962
|
+
texts,
|
|
963
|
+
model=model,
|
|
964
|
+
provider=provider,
|
|
965
|
+
chunk_size=chunk_size,
|
|
966
|
+
concurrency=concurrency,
|
|
967
|
+
)
|
|
968
|
+
|
|
969
|
+
@classmethod
|
|
970
|
+
def vectorstore(
|
|
971
|
+
cls,
|
|
972
|
+
adapter: str = "in_memory",
|
|
973
|
+
collection: str = "default",
|
|
974
|
+
dimensions: int = 1536,
|
|
975
|
+
metric: str = "cosine",
|
|
976
|
+
**kwargs: Any,
|
|
977
|
+
) -> VectorStore:
|
|
978
|
+
"""
|
|
979
|
+
Instantiate a VectorStore adapter ('in_memory', 'pgvector', 'qdrant', 'lancedb', 'chroma', 'pinecone').
|
|
980
|
+
"""
|
|
981
|
+
norm_adapter = adapter.lower().strip()
|
|
982
|
+
if norm_adapter in ("in_memory", "memory"):
|
|
983
|
+
return VectorStore.in_memory(collection=collection, dimensions=dimensions, metric=metric, **kwargs)
|
|
984
|
+
if norm_adapter in ("pgvector", "postgres", "postgresql"):
|
|
985
|
+
return VectorStore.pgvector(collection=collection, dimensions=dimensions, metric=metric, **kwargs)
|
|
986
|
+
if norm_adapter == "qdrant":
|
|
987
|
+
return VectorStore.qdrant(collection=collection, dimensions=dimensions, metric=metric, **kwargs)
|
|
988
|
+
if norm_adapter == "lancedb":
|
|
989
|
+
return VectorStore.lancedb(collection=collection, dimensions=dimensions, metric=metric, **kwargs)
|
|
990
|
+
if norm_adapter == "chroma":
|
|
991
|
+
return VectorStore.chroma(collection=collection, dimensions=dimensions, metric=metric, **kwargs)
|
|
992
|
+
if norm_adapter == "pinecone":
|
|
993
|
+
return VectorStore.pinecone(collection=collection, dimensions=dimensions, metric=metric, **kwargs)
|
|
994
|
+
|
|
995
|
+
raise ValueError(
|
|
996
|
+
f"Unknown vectorstore adapter '{adapter}'. "
|
|
997
|
+
f"Expected one of 'in_memory', 'pgvector', 'qdrant', 'lancedb', 'chroma', 'pinecone'."
|
|
998
|
+
)
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def ai(
|
|
1002
|
+
config: Config | None = None,
|
|
1003
|
+
http_pool: AIHttpPool | None = None,
|
|
1004
|
+
default_provider: str | None = None,
|
|
1005
|
+
) -> AIClient:
|
|
1006
|
+
"""Convenience factory returning a new fluent AIClient builder."""
|
|
1007
|
+
return AIClient(
|
|
1008
|
+
config=config,
|
|
1009
|
+
http_pool=http_pool,
|
|
1010
|
+
default_provider=default_provider,
|
|
1011
|
+
)
|