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/_fake.py
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextvars
|
|
5
|
+
import time
|
|
6
|
+
import types
|
|
7
|
+
from collections.abc import AsyncIterator, Callable, Mapping, Sequence
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Self
|
|
11
|
+
|
|
12
|
+
from swiftpy.ai._deterministic import generate_deterministic_embedding
|
|
13
|
+
from swiftpy.ai.observability.observer import (
|
|
14
|
+
ObservationContext,
|
|
15
|
+
compute_prompt_hash,
|
|
16
|
+
get_observability,
|
|
17
|
+
)
|
|
18
|
+
from swiftpy.ai.providers.base import (
|
|
19
|
+
LLMRequest,
|
|
20
|
+
LLMResponse,
|
|
21
|
+
Message,
|
|
22
|
+
TokenUsage,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from swiftpy.ai.client import AIClient
|
|
27
|
+
|
|
28
|
+
FixtureType = (
|
|
29
|
+
str
|
|
30
|
+
| LLMResponse
|
|
31
|
+
| Sequence[str | LLMResponse]
|
|
32
|
+
| Mapping[str, str | LLMResponse]
|
|
33
|
+
| Callable[[LLMRequest], str | LLMResponse]
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
_task_fake_stack: contextvars.ContextVar[tuple[AIFake, ...]] = contextvars.ContextVar(
|
|
37
|
+
"_swiftpy_ai_fake_stack", default=()
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class FakeCall:
|
|
43
|
+
"""Telemetry record for an intercepted fake LLM call."""
|
|
44
|
+
|
|
45
|
+
request: LLMRequest
|
|
46
|
+
messages: list[Message]
|
|
47
|
+
model: str | None
|
|
48
|
+
provider: str | None
|
|
49
|
+
temperature: float | None
|
|
50
|
+
max_tokens: int | None
|
|
51
|
+
response: LLMResponse
|
|
52
|
+
timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def prompt_text(self) -> str:
|
|
56
|
+
"""Concatenated contents of all request messages."""
|
|
57
|
+
return "\n".join(m.content or "" for m in self.messages)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def user_content(self) -> str:
|
|
61
|
+
"""Content of the most recent user message."""
|
|
62
|
+
for m in reversed(self.messages):
|
|
63
|
+
if m.role == "user":
|
|
64
|
+
return m.content or ""
|
|
65
|
+
return ""
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def system_content(self) -> str:
|
|
69
|
+
"""Content of the most recent system message."""
|
|
70
|
+
for m in reversed(self.messages):
|
|
71
|
+
if m.role == "system":
|
|
72
|
+
return m.content or ""
|
|
73
|
+
return ""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class FakeEmbeddingCall:
|
|
78
|
+
"""Telemetry record for an intercepted fake embedding call."""
|
|
79
|
+
|
|
80
|
+
texts: list[str]
|
|
81
|
+
model: str | None
|
|
82
|
+
provider: str | None
|
|
83
|
+
dimensions: int
|
|
84
|
+
timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class AIFake:
|
|
88
|
+
"""
|
|
89
|
+
Test fake that intercepts all LLM and embedding calls across the application.
|
|
90
|
+
Returns configured canned fixtures, records all invocations,
|
|
91
|
+
triggers observability telemetry without making real external API calls,
|
|
92
|
+
isolates calls per asyncio task via ContextVar, and exposes expressive assertion helpers.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
_global_fake: ClassVar[AIFake | None] = None
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
fixtures: FixtureType | None = None,
|
|
100
|
+
embedding_fixtures: (
|
|
101
|
+
Mapping[str, list[float]]
|
|
102
|
+
| list[list[float]]
|
|
103
|
+
| Callable[[Sequence[str]], list[list[float]]]
|
|
104
|
+
| None
|
|
105
|
+
) = None,
|
|
106
|
+
) -> None:
|
|
107
|
+
self._fixtures = fixtures
|
|
108
|
+
self._embedding_fixtures = embedding_fixtures
|
|
109
|
+
self._queue: list[str | LLMResponse] = []
|
|
110
|
+
self._calls: list[FakeCall] = []
|
|
111
|
+
self._embedding_calls: list[FakeEmbeddingCall] = []
|
|
112
|
+
self._is_active = False
|
|
113
|
+
|
|
114
|
+
self._init_fixtures(fixtures)
|
|
115
|
+
|
|
116
|
+
def _init_fixtures(self, fixtures: FixtureType | None) -> None:
|
|
117
|
+
if isinstance(fixtures, (list, tuple)):
|
|
118
|
+
self._queue = list(fixtures)
|
|
119
|
+
else:
|
|
120
|
+
self._queue = []
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
def get_active(cls) -> AIFake | None:
|
|
124
|
+
"""
|
|
125
|
+
Return the currently active fake instance.
|
|
126
|
+
Resolves task-local active fake first, falling back to global fake if set.
|
|
127
|
+
"""
|
|
128
|
+
stack = _task_fake_stack.get()
|
|
129
|
+
if stack:
|
|
130
|
+
return stack[-1]
|
|
131
|
+
return cls._global_fake
|
|
132
|
+
|
|
133
|
+
@classmethod
|
|
134
|
+
def set_active(cls, fake: AIFake | None) -> None:
|
|
135
|
+
"""Set the active fake instance globally and in current task context."""
|
|
136
|
+
cls._global_fake = fake
|
|
137
|
+
if fake is None:
|
|
138
|
+
_task_fake_stack.set(())
|
|
139
|
+
else:
|
|
140
|
+
stack = _task_fake_stack.get()
|
|
141
|
+
if fake not in stack:
|
|
142
|
+
_task_fake_stack.set((*stack, fake))
|
|
143
|
+
|
|
144
|
+
def activate(self) -> Self:
|
|
145
|
+
"""Activate interception of all LLM and embedding calls in current task."""
|
|
146
|
+
stack = _task_fake_stack.get()
|
|
147
|
+
if self not in stack:
|
|
148
|
+
_task_fake_stack.set((*stack, self))
|
|
149
|
+
self._is_active = True
|
|
150
|
+
return self
|
|
151
|
+
|
|
152
|
+
def restore(self) -> None:
|
|
153
|
+
"""Deactivate interception and restore previous active fake or live execution."""
|
|
154
|
+
stack = _task_fake_stack.get()
|
|
155
|
+
if self in stack:
|
|
156
|
+
idx = len(stack) - 1 - stack[::-1].index(self)
|
|
157
|
+
_task_fake_stack.set(stack[:idx] + stack[idx + 1 :])
|
|
158
|
+
if AIFake._global_fake is self:
|
|
159
|
+
AIFake._global_fake = None
|
|
160
|
+
self._is_active = False
|
|
161
|
+
|
|
162
|
+
def reset(self) -> None:
|
|
163
|
+
"""Clear recorded calls and reset fixture queue."""
|
|
164
|
+
self._calls.clear()
|
|
165
|
+
self._embedding_calls.clear()
|
|
166
|
+
self._init_fixtures(self._fixtures)
|
|
167
|
+
|
|
168
|
+
def __enter__(self) -> Self:
|
|
169
|
+
return self.activate()
|
|
170
|
+
|
|
171
|
+
def __exit__(
|
|
172
|
+
self,
|
|
173
|
+
exc_type: type[BaseException] | None,
|
|
174
|
+
exc_val: BaseException | None,
|
|
175
|
+
exc_tb: types.TracebackType | None,
|
|
176
|
+
) -> None:
|
|
177
|
+
self.restore()
|
|
178
|
+
|
|
179
|
+
async def __aenter__(self) -> Self:
|
|
180
|
+
return self.__enter__()
|
|
181
|
+
|
|
182
|
+
async def __aexit__(
|
|
183
|
+
self,
|
|
184
|
+
exc_type: type[BaseException] | None,
|
|
185
|
+
exc_val: BaseException | None,
|
|
186
|
+
exc_tb: types.TracebackType | None,
|
|
187
|
+
) -> None:
|
|
188
|
+
self.__exit__(exc_type, exc_val, exc_tb)
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def calls(self) -> list[FakeCall]:
|
|
192
|
+
"""Defensive copy of recorded LLM calls."""
|
|
193
|
+
return list(self._calls)
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def call_count(self) -> int:
|
|
197
|
+
"""Total number of intercepted LLM calls."""
|
|
198
|
+
return len(self._calls)
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def embedding_calls(self) -> list[FakeEmbeddingCall]:
|
|
202
|
+
"""Defensive copy of recorded embedding calls."""
|
|
203
|
+
return list(self._embedding_calls)
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def embedding_call_count(self) -> int:
|
|
207
|
+
"""Total number of intercepted embedding calls."""
|
|
208
|
+
return len(self._embedding_calls)
|
|
209
|
+
|
|
210
|
+
def _coerce_fixture(
|
|
211
|
+
self,
|
|
212
|
+
value: str | LLMResponse,
|
|
213
|
+
model_name: str,
|
|
214
|
+
provider: str,
|
|
215
|
+
) -> LLMResponse:
|
|
216
|
+
"""Convert a fixture string or LLMResponse into an LLMResponse instance."""
|
|
217
|
+
if isinstance(value, LLMResponse):
|
|
218
|
+
return value
|
|
219
|
+
return self._build_response(str(value), model_name, provider)
|
|
220
|
+
|
|
221
|
+
def _resolve_call_identity(
|
|
222
|
+
self,
|
|
223
|
+
client: AIClient,
|
|
224
|
+
request: LLMRequest,
|
|
225
|
+
) -> tuple[str, str]:
|
|
226
|
+
"""Resolve effective provider and model names for a faked call."""
|
|
227
|
+
provider_name = client._provider_name or str(
|
|
228
|
+
request.metadata.get("provider") or "fake_provider"
|
|
229
|
+
)
|
|
230
|
+
model_name = request.model or client._model or "fake-model"
|
|
231
|
+
return provider_name, model_name
|
|
232
|
+
|
|
233
|
+
def _generate_response(self, request: LLMRequest, provider: str) -> LLMResponse:
|
|
234
|
+
"""Resolve next canned response for the given request."""
|
|
235
|
+
model_name = request.model or "fake-model"
|
|
236
|
+
|
|
237
|
+
if callable(self._fixtures):
|
|
238
|
+
return self._coerce_fixture(self._fixtures(request), model_name, provider)
|
|
239
|
+
|
|
240
|
+
if isinstance(self._fixtures, Mapping):
|
|
241
|
+
model_lower = model_name.lower()
|
|
242
|
+
# 1. Exact model match first
|
|
243
|
+
for key, val in self._fixtures.items():
|
|
244
|
+
key_lower = key.lower()
|
|
245
|
+
if key_lower not in ("*", "default") and key_lower == model_lower:
|
|
246
|
+
return self._coerce_fixture(val, model_name, provider)
|
|
247
|
+
# 2. Longest model prefix / substring match next
|
|
248
|
+
sorted_candidates = sorted(
|
|
249
|
+
(
|
|
250
|
+
(key, val)
|
|
251
|
+
for key, val in self._fixtures.items()
|
|
252
|
+
if key and key.lower() not in ("*", "default")
|
|
253
|
+
),
|
|
254
|
+
key=lambda item: len(item[0]),
|
|
255
|
+
reverse=True,
|
|
256
|
+
)
|
|
257
|
+
for key, val in sorted_candidates:
|
|
258
|
+
if key.lower() in model_lower:
|
|
259
|
+
return self._coerce_fixture(val, model_name, provider)
|
|
260
|
+
# 3. Provider match (case-insensitive)
|
|
261
|
+
provider_lower = provider.lower()
|
|
262
|
+
for key, val in self._fixtures.items():
|
|
263
|
+
if key.lower() == provider_lower:
|
|
264
|
+
return self._coerce_fixture(val, model_name, provider)
|
|
265
|
+
# 4. Default fallback key if provided
|
|
266
|
+
fallback_val = self._fixtures.get("*")
|
|
267
|
+
if fallback_val is None:
|
|
268
|
+
fallback_val = self._fixtures.get("default")
|
|
269
|
+
if fallback_val is not None:
|
|
270
|
+
return self._coerce_fixture(fallback_val, model_name, provider)
|
|
271
|
+
|
|
272
|
+
if self._queue:
|
|
273
|
+
return self._coerce_fixture(self._queue.pop(0), model_name, provider)
|
|
274
|
+
|
|
275
|
+
if isinstance(self._fixtures, (str, LLMResponse)):
|
|
276
|
+
return self._coerce_fixture(self._fixtures, model_name, provider)
|
|
277
|
+
|
|
278
|
+
# Default fallback response
|
|
279
|
+
return self._build_response(
|
|
280
|
+
"Fake LLM completion response.",
|
|
281
|
+
model_name,
|
|
282
|
+
provider,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
def _build_response(self, content: str, model: str, provider: str) -> LLMResponse:
|
|
286
|
+
prompt_tokens = 10
|
|
287
|
+
completion_tokens = max(1, len(content) // 4)
|
|
288
|
+
return LLMResponse(
|
|
289
|
+
content=content,
|
|
290
|
+
model=model,
|
|
291
|
+
usage=TokenUsage(
|
|
292
|
+
prompt_tokens=prompt_tokens,
|
|
293
|
+
completion_tokens=completion_tokens,
|
|
294
|
+
total_tokens=prompt_tokens + completion_tokens,
|
|
295
|
+
),
|
|
296
|
+
finish_reason="stop",
|
|
297
|
+
raw={"faked": True, "provider": provider},
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
async def handle_send(self, client: AIClient, request: LLMRequest) -> LLMResponse:
|
|
301
|
+
"""
|
|
302
|
+
Intercept AIClient.send() call, record call, notify observability,
|
|
303
|
+
and return canned response.
|
|
304
|
+
"""
|
|
305
|
+
provider_name, model_name = self._resolve_call_identity(client, request)
|
|
306
|
+
|
|
307
|
+
observer = get_observability()
|
|
308
|
+
async with observer.observe_call(
|
|
309
|
+
model=model_name,
|
|
310
|
+
provider=provider_name,
|
|
311
|
+
messages=request.messages,
|
|
312
|
+
metadata={"faked": True, **dict(request.extra), **dict(request.metadata)},
|
|
313
|
+
) as obs_ctx:
|
|
314
|
+
# Simulate a brief asynchronous pause
|
|
315
|
+
await asyncio.sleep(0.001)
|
|
316
|
+
|
|
317
|
+
response = self._generate_response(request, provider_name)
|
|
318
|
+
obs_ctx.set_response(response)
|
|
319
|
+
|
|
320
|
+
record = FakeCall(
|
|
321
|
+
request=request,
|
|
322
|
+
messages=list(request.messages),
|
|
323
|
+
model=model_name,
|
|
324
|
+
provider=provider_name,
|
|
325
|
+
temperature=request.temperature,
|
|
326
|
+
max_tokens=request.max_tokens,
|
|
327
|
+
response=response,
|
|
328
|
+
)
|
|
329
|
+
self._calls.append(record)
|
|
330
|
+
|
|
331
|
+
return response
|
|
332
|
+
|
|
333
|
+
async def handle_stream(
|
|
334
|
+
self, client: AIClient, request: LLMRequest
|
|
335
|
+
) -> AsyncIterator[str]:
|
|
336
|
+
"""
|
|
337
|
+
Intercept AIClient.stream() call, record call, notify observability,
|
|
338
|
+
and yield canned tokens incrementally with TTFT tracking.
|
|
339
|
+
"""
|
|
340
|
+
provider_name, model_name = self._resolve_call_identity(client, request)
|
|
341
|
+
|
|
342
|
+
observer = get_observability()
|
|
343
|
+
prompt_hash = compute_prompt_hash(request.messages)
|
|
344
|
+
obs_ctx = ObservationContext(
|
|
345
|
+
model=model_name,
|
|
346
|
+
provider=provider_name,
|
|
347
|
+
prompt_hash=prompt_hash,
|
|
348
|
+
metadata={
|
|
349
|
+
"faked": True,
|
|
350
|
+
"stream": True,
|
|
351
|
+
**dict(request.extra),
|
|
352
|
+
**dict(request.metadata),
|
|
353
|
+
},
|
|
354
|
+
)
|
|
355
|
+
response = self._generate_response(request, provider_name)
|
|
356
|
+
obs_ctx.set_response(response)
|
|
357
|
+
|
|
358
|
+
record = FakeCall(
|
|
359
|
+
request=request,
|
|
360
|
+
messages=list(request.messages),
|
|
361
|
+
model=model_name,
|
|
362
|
+
provider=provider_name,
|
|
363
|
+
temperature=request.temperature,
|
|
364
|
+
max_tokens=request.max_tokens,
|
|
365
|
+
response=response,
|
|
366
|
+
)
|
|
367
|
+
self._calls.append(record)
|
|
368
|
+
|
|
369
|
+
content = response.content or ""
|
|
370
|
+
words = content.split(" ") if content else []
|
|
371
|
+
stream_start = time.perf_counter()
|
|
372
|
+
first_chunk = True
|
|
373
|
+
completed = False
|
|
374
|
+
|
|
375
|
+
try:
|
|
376
|
+
for i, word in enumerate(words):
|
|
377
|
+
chunk = word if i == len(words) - 1 else f"{word} "
|
|
378
|
+
await asyncio.sleep(0.001)
|
|
379
|
+
if first_chunk:
|
|
380
|
+
obs_ctx.record_first_token(stream_start)
|
|
381
|
+
first_chunk = False
|
|
382
|
+
yield chunk
|
|
383
|
+
completed = True
|
|
384
|
+
except (asyncio.CancelledError, GeneratorExit) as exc:
|
|
385
|
+
obs_ctx.status = "cancelled"
|
|
386
|
+
obs_ctx.error_message = str(exc) or "Stream consumer disconnected or cancelled."
|
|
387
|
+
raise
|
|
388
|
+
except Exception as exc:
|
|
389
|
+
obs_ctx.status = "error"
|
|
390
|
+
obs_ctx.error_message = str(exc)
|
|
391
|
+
raise
|
|
392
|
+
finally:
|
|
393
|
+
latency_ms = (time.perf_counter() - stream_start) * 1000.0
|
|
394
|
+
obs_ctx.duration_ms = latency_ms
|
|
395
|
+
if not completed and obs_ctx.status == "success":
|
|
396
|
+
obs_ctx.status = "cancelled"
|
|
397
|
+
await observer.record(
|
|
398
|
+
model=obs_ctx.model,
|
|
399
|
+
provider=obs_ctx.provider,
|
|
400
|
+
input_tokens=obs_ctx.input_tokens,
|
|
401
|
+
output_tokens=obs_ctx.output_tokens,
|
|
402
|
+
latency_ms=latency_ms,
|
|
403
|
+
prompt_hash=obs_ctx.prompt_hash,
|
|
404
|
+
cost=obs_ctx.cost,
|
|
405
|
+
status=obs_ctx.status,
|
|
406
|
+
error_message=obs_ctx.error_message,
|
|
407
|
+
metadata=obs_ctx.metadata,
|
|
408
|
+
ttft_ms=obs_ctx.ttft_ms,
|
|
409
|
+
duration_ms=obs_ctx.duration_ms,
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
async def handle_embed(
|
|
413
|
+
self,
|
|
414
|
+
texts: Sequence[str],
|
|
415
|
+
model: str | None = None,
|
|
416
|
+
provider: str | None = None,
|
|
417
|
+
dimensions: int = 1536,
|
|
418
|
+
) -> list[list[float]]:
|
|
419
|
+
"""
|
|
420
|
+
Intercept embedding call, record call in telemetry, and return deterministic or fixture vectors.
|
|
421
|
+
"""
|
|
422
|
+
text_list = list(texts)
|
|
423
|
+
call = FakeEmbeddingCall(
|
|
424
|
+
texts=text_list,
|
|
425
|
+
model=model,
|
|
426
|
+
provider=provider,
|
|
427
|
+
dimensions=dimensions,
|
|
428
|
+
)
|
|
429
|
+
self._embedding_calls.append(call)
|
|
430
|
+
|
|
431
|
+
if callable(self._embedding_fixtures):
|
|
432
|
+
return self._embedding_fixtures(text_list)
|
|
433
|
+
|
|
434
|
+
if isinstance(self._embedding_fixtures, Mapping):
|
|
435
|
+
vectors: list[list[float]] = []
|
|
436
|
+
for t in text_list:
|
|
437
|
+
if t in self._embedding_fixtures:
|
|
438
|
+
vectors.append(list(self._embedding_fixtures[t]))
|
|
439
|
+
elif "*" in self._embedding_fixtures:
|
|
440
|
+
vectors.append(list(self._embedding_fixtures["*"]))
|
|
441
|
+
else:
|
|
442
|
+
vectors.append(generate_deterministic_embedding(t, dimensions=dimensions))
|
|
443
|
+
return vectors
|
|
444
|
+
|
|
445
|
+
if isinstance(self._embedding_fixtures, list) and self._embedding_fixtures:
|
|
446
|
+
fixture_count = len(self._embedding_fixtures)
|
|
447
|
+
return [
|
|
448
|
+
list(self._embedding_fixtures[i % fixture_count])
|
|
449
|
+
for i in range(len(text_list))
|
|
450
|
+
]
|
|
451
|
+
|
|
452
|
+
return [generate_deterministic_embedding(t, dimensions=dimensions) for t in text_list]
|
|
453
|
+
|
|
454
|
+
# --------------------------------------------------------------------------
|
|
455
|
+
# Assertion Helpers
|
|
456
|
+
# --------------------------------------------------------------------------
|
|
457
|
+
|
|
458
|
+
def assert_called(self, times: int | None = None) -> None:
|
|
459
|
+
"""
|
|
460
|
+
Assert that the fake was invoked at least once, or exactly `times` times.
|
|
461
|
+
"""
|
|
462
|
+
count = len(self._calls)
|
|
463
|
+
if times is None:
|
|
464
|
+
if count == 0:
|
|
465
|
+
raise AssertionError("Expected AI to be called, but zero calls were made.")
|
|
466
|
+
elif count != times:
|
|
467
|
+
raise AssertionError(
|
|
468
|
+
f"Expected AI to be called exactly {times} times, but was called {count} times."
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
def assert_called_once(self) -> None:
|
|
472
|
+
"""Assert that the fake was invoked exactly once."""
|
|
473
|
+
self.assert_called(times=1)
|
|
474
|
+
|
|
475
|
+
def assert_not_called(self) -> None:
|
|
476
|
+
"""Assert that no AI calls were made."""
|
|
477
|
+
self.assert_called(times=0)
|
|
478
|
+
|
|
479
|
+
def assert_called_with(
|
|
480
|
+
self,
|
|
481
|
+
*,
|
|
482
|
+
model: str | None = None,
|
|
483
|
+
provider: str | None = None,
|
|
484
|
+
prompt: str | None = None,
|
|
485
|
+
user: str | None = None,
|
|
486
|
+
system: str | None = None,
|
|
487
|
+
messages: Sequence[Message | dict[str, Any]] | None = None,
|
|
488
|
+
) -> None:
|
|
489
|
+
"""
|
|
490
|
+
Assert that at least one recorded call matched all specified parameters.
|
|
491
|
+
"""
|
|
492
|
+
if not self._calls:
|
|
493
|
+
raise AssertionError(
|
|
494
|
+
"Expected AI call matching criteria, but zero calls were made."
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
matched = False
|
|
498
|
+
reasons: list[str] = []
|
|
499
|
+
|
|
500
|
+
for idx, call in enumerate(self._calls):
|
|
501
|
+
mismatch = []
|
|
502
|
+
if model is not None and call.model != model:
|
|
503
|
+
mismatch.append(f"model: expected '{model}', got '{call.model}'")
|
|
504
|
+
if provider is not None and call.provider != provider:
|
|
505
|
+
mismatch.append(f"provider: expected '{provider}', got '{call.provider}'")
|
|
506
|
+
if prompt is not None and prompt not in call.prompt_text:
|
|
507
|
+
mismatch.append(f"prompt substring '{prompt}' not found in '{call.prompt_text}'")
|
|
508
|
+
if user is not None and user not in call.user_content:
|
|
509
|
+
mismatch.append(f"user content '{user}' not found in '{call.user_content}'")
|
|
510
|
+
if system is not None and system not in call.system_content:
|
|
511
|
+
mismatch.append(f"system content '{system}' not found in '{call.system_content}'")
|
|
512
|
+
|
|
513
|
+
if messages is not None:
|
|
514
|
+
if len(call.messages) != len(messages):
|
|
515
|
+
mismatch.append(
|
|
516
|
+
f"messages length: expected {len(messages)}, got {len(call.messages)}"
|
|
517
|
+
)
|
|
518
|
+
else:
|
|
519
|
+
for i, (actual, expected) in enumerate(zip(call.messages, messages)):
|
|
520
|
+
if isinstance(expected, Message):
|
|
521
|
+
if (
|
|
522
|
+
actual.role != expected.role
|
|
523
|
+
or actual.content != expected.content
|
|
524
|
+
):
|
|
525
|
+
mismatch.append(
|
|
526
|
+
f"message[{i}]: expected {expected}, got {actual}"
|
|
527
|
+
)
|
|
528
|
+
elif (
|
|
529
|
+
isinstance(expected, dict)
|
|
530
|
+
and (
|
|
531
|
+
actual.role != expected.get("role")
|
|
532
|
+
or actual.content != expected.get("content")
|
|
533
|
+
)
|
|
534
|
+
):
|
|
535
|
+
mismatch.append(
|
|
536
|
+
f"message[{i}]: expected {expected}, got {actual}"
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
if not mismatch:
|
|
540
|
+
matched = True
|
|
541
|
+
break
|
|
542
|
+
reasons.append(f"Call #{idx + 1}: " + "; ".join(mismatch))
|
|
543
|
+
|
|
544
|
+
if not matched:
|
|
545
|
+
details = "\n ".join(reasons)
|
|
546
|
+
raise AssertionError(
|
|
547
|
+
f"No AI call matched criteria.\nActual calls mismatch details:\n {details}"
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
def assert_embedding_called(self, times: int | None = None) -> None:
|
|
551
|
+
"""Assert that an embedding call was intercepted."""
|
|
552
|
+
count = len(self._embedding_calls)
|
|
553
|
+
if times is None:
|
|
554
|
+
if count == 0:
|
|
555
|
+
raise AssertionError("Expected embedding to be called, but zero calls were made.")
|
|
556
|
+
elif count != times:
|
|
557
|
+
raise AssertionError(
|
|
558
|
+
f"Expected embedding to be called exactly {times} times, but was called {count} times."
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
def assert_embedding_called_once(self) -> None:
|
|
562
|
+
"""Assert that exactly one embedding call was made."""
|
|
563
|
+
self.assert_embedding_called(times=1)
|
|
564
|
+
|
|
565
|
+
def assert_embedding_not_called(self) -> None:
|
|
566
|
+
"""Assert that zero embedding calls were made."""
|
|
567
|
+
self.assert_embedding_called(times=0)
|
|
568
|
+
|
|
569
|
+
def assert_embedding_called_with(
|
|
570
|
+
self,
|
|
571
|
+
*,
|
|
572
|
+
text: str | None = None,
|
|
573
|
+
texts: Sequence[str] | None = None,
|
|
574
|
+
model: str | None = None,
|
|
575
|
+
provider: str | None = None,
|
|
576
|
+
) -> None:
|
|
577
|
+
"""Assert that at least one recorded embedding call matched criteria."""
|
|
578
|
+
if not self._embedding_calls:
|
|
579
|
+
raise AssertionError("Expected embedding call matching criteria, but zero calls were made.")
|
|
580
|
+
|
|
581
|
+
matched = False
|
|
582
|
+
reasons: list[str] = []
|
|
583
|
+
|
|
584
|
+
for idx, call in enumerate(self._embedding_calls):
|
|
585
|
+
mismatch = []
|
|
586
|
+
if text is not None and text not in call.texts:
|
|
587
|
+
mismatch.append(f"text '{text}' not found in call texts: {call.texts}")
|
|
588
|
+
if texts is not None and list(texts) != call.texts:
|
|
589
|
+
mismatch.append(f"texts: expected {list(texts)}, got {call.texts}")
|
|
590
|
+
if model is not None and call.model != model:
|
|
591
|
+
mismatch.append(f"model: expected '{model}', got '{call.model}'")
|
|
592
|
+
if provider is not None and call.provider != provider:
|
|
593
|
+
mismatch.append(f"provider: expected '{provider}', got '{call.provider}'")
|
|
594
|
+
|
|
595
|
+
if not mismatch:
|
|
596
|
+
matched = True
|
|
597
|
+
break
|
|
598
|
+
reasons.append(f"Embedding call #{idx + 1}: " + "; ".join(mismatch))
|
|
599
|
+
|
|
600
|
+
if not matched:
|
|
601
|
+
details = "\n ".join(reasons)
|
|
602
|
+
raise AssertionError(
|
|
603
|
+
f"No embedding call matched criteria.\nActual embedding details:\n {details}"
|
|
604
|
+
)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from swiftpy.ai.agents.base_agent import (
|
|
4
|
+
Agent,
|
|
5
|
+
AgentResponse,
|
|
6
|
+
AgentTask,
|
|
7
|
+
AgentTracker,
|
|
8
|
+
BaseAgent,
|
|
9
|
+
)
|
|
10
|
+
from swiftpy.ai.agents.memory import (
|
|
11
|
+
AgentMemory,
|
|
12
|
+
BaseMemoryBackend,
|
|
13
|
+
DatabaseMemoryBackend,
|
|
14
|
+
InMemoryMemoryBackend,
|
|
15
|
+
RedisMemoryBackend,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"Agent",
|
|
20
|
+
"AgentMemory",
|
|
21
|
+
"AgentResponse",
|
|
22
|
+
"AgentTask",
|
|
23
|
+
"AgentTracker",
|
|
24
|
+
"BaseAgent",
|
|
25
|
+
"BaseMemoryBackend",
|
|
26
|
+
"DatabaseMemoryBackend",
|
|
27
|
+
"InMemoryMemoryBackend",
|
|
28
|
+
"RedisMemoryBackend",
|
|
29
|
+
]
|
|
30
|
+
|