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