roomkit 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.
- roomkit/AGENTS.md +362 -0
- roomkit/__init__.py +372 -0
- roomkit/_version.py +1 -0
- roomkit/ai_docs.py +93 -0
- roomkit/channels/__init__.py +194 -0
- roomkit/channels/ai.py +238 -0
- roomkit/channels/base.py +66 -0
- roomkit/channels/transport.py +115 -0
- roomkit/channels/websocket.py +85 -0
- roomkit/core/__init__.py +0 -0
- roomkit/core/_channel_ops.py +252 -0
- roomkit/core/_helpers.py +296 -0
- roomkit/core/_inbound.py +435 -0
- roomkit/core/_room_lifecycle.py +275 -0
- roomkit/core/circuit_breaker.py +84 -0
- roomkit/core/event_router.py +401 -0
- roomkit/core/framework.py +793 -0
- roomkit/core/hooks.py +232 -0
- roomkit/core/inbound_router.py +57 -0
- roomkit/core/locks.py +66 -0
- roomkit/core/rate_limiter.py +67 -0
- roomkit/core/retry.py +49 -0
- roomkit/core/router.py +24 -0
- roomkit/core/transcoder.py +85 -0
- roomkit/identity/__init__.py +0 -0
- roomkit/identity/base.py +27 -0
- roomkit/identity/mock.py +49 -0
- roomkit/llms.txt +52 -0
- roomkit/models/__init__.py +104 -0
- roomkit/models/channel.py +99 -0
- roomkit/models/context.py +35 -0
- roomkit/models/delivery.py +76 -0
- roomkit/models/enums.py +170 -0
- roomkit/models/event.py +203 -0
- roomkit/models/framework_event.py +19 -0
- roomkit/models/hook.py +68 -0
- roomkit/models/identity.py +81 -0
- roomkit/models/participant.py +34 -0
- roomkit/models/room.py +33 -0
- roomkit/models/task.py +36 -0
- roomkit/providers/__init__.py +0 -0
- roomkit/providers/ai/__init__.py +0 -0
- roomkit/providers/ai/base.py +140 -0
- roomkit/providers/ai/mock.py +33 -0
- roomkit/providers/anthropic/__init__.py +6 -0
- roomkit/providers/anthropic/ai.py +145 -0
- roomkit/providers/anthropic/config.py +14 -0
- roomkit/providers/elasticemail/__init__.py +6 -0
- roomkit/providers/elasticemail/config.py +16 -0
- roomkit/providers/elasticemail/email.py +97 -0
- roomkit/providers/email/__init__.py +0 -0
- roomkit/providers/email/base.py +46 -0
- roomkit/providers/email/mock.py +34 -0
- roomkit/providers/gemini/__init__.py +6 -0
- roomkit/providers/gemini/ai.py +153 -0
- roomkit/providers/gemini/config.py +14 -0
- roomkit/providers/http/__init__.py +15 -0
- roomkit/providers/http/base.py +33 -0
- roomkit/providers/http/config.py +14 -0
- roomkit/providers/http/mock.py +21 -0
- roomkit/providers/http/provider.py +105 -0
- roomkit/providers/http/webhook.py +33 -0
- roomkit/providers/messenger/__init__.py +15 -0
- roomkit/providers/messenger/base.py +33 -0
- roomkit/providers/messenger/config.py +17 -0
- roomkit/providers/messenger/facebook.py +95 -0
- roomkit/providers/messenger/mock.py +21 -0
- roomkit/providers/messenger/webhook.py +42 -0
- roomkit/providers/openai/__init__.py +6 -0
- roomkit/providers/openai/ai.py +155 -0
- roomkit/providers/openai/config.py +24 -0
- roomkit/providers/pydantic_ai/__init__.py +5 -0
- roomkit/providers/pydantic_ai/config.py +14 -0
- roomkit/providers/rcs/__init__.py +9 -0
- roomkit/providers/rcs/base.py +95 -0
- roomkit/providers/rcs/mock.py +78 -0
- roomkit/providers/sendgrid/__init__.py +5 -0
- roomkit/providers/sendgrid/config.py +13 -0
- roomkit/providers/sinch/__init__.py +6 -0
- roomkit/providers/sinch/config.py +22 -0
- roomkit/providers/sinch/sms.py +192 -0
- roomkit/providers/sms/__init__.py +15 -0
- roomkit/providers/sms/base.py +67 -0
- roomkit/providers/sms/meta.py +401 -0
- roomkit/providers/sms/mock.py +24 -0
- roomkit/providers/sms/phone.py +77 -0
- roomkit/providers/telnyx/__init__.py +21 -0
- roomkit/providers/telnyx/config.py +14 -0
- roomkit/providers/telnyx/rcs.py +352 -0
- roomkit/providers/telnyx/sms.py +231 -0
- roomkit/providers/twilio/__init__.py +18 -0
- roomkit/providers/twilio/config.py +19 -0
- roomkit/providers/twilio/rcs.py +183 -0
- roomkit/providers/twilio/sms.py +200 -0
- roomkit/providers/voicemeup/__init__.py +15 -0
- roomkit/providers/voicemeup/config.py +21 -0
- roomkit/providers/voicemeup/sms.py +374 -0
- roomkit/providers/whatsapp/__init__.py +0 -0
- roomkit/providers/whatsapp/base.py +44 -0
- roomkit/providers/whatsapp/mock.py +21 -0
- roomkit/py.typed +0 -0
- roomkit/realtime/__init__.py +17 -0
- roomkit/realtime/base.py +111 -0
- roomkit/realtime/memory.py +158 -0
- roomkit/sources/__init__.py +35 -0
- roomkit/sources/base.py +207 -0
- roomkit/sources/websocket.py +260 -0
- roomkit/store/__init__.py +0 -0
- roomkit/store/base.py +230 -0
- roomkit/store/memory.py +293 -0
- roomkit-0.1.0.dist-info/METADATA +567 -0
- roomkit-0.1.0.dist-info/RECORD +114 -0
- roomkit-0.1.0.dist-info/WHEEL +4 -0
- roomkit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Generic HTTP webhook provider — POSTs events to a configurable URL."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
9
|
+
|
|
10
|
+
from roomkit.models.delivery import ProviderResult
|
|
11
|
+
from roomkit.models.event import RoomEvent, TextContent
|
|
12
|
+
from roomkit.providers.http.base import HTTPProvider
|
|
13
|
+
from roomkit.providers.http.config import HTTPProviderConfig
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class WebhookHTTPProvider(HTTPProvider):
|
|
20
|
+
"""HTTP provider that POSTs JSON payloads to a webhook URL."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, config: HTTPProviderConfig) -> None:
|
|
23
|
+
try:
|
|
24
|
+
import httpx as _httpx
|
|
25
|
+
except ImportError as exc:
|
|
26
|
+
raise ImportError(
|
|
27
|
+
"httpx is required for WebhookHTTPProvider. "
|
|
28
|
+
"Install it with: pip install roomkit[httpx]"
|
|
29
|
+
) from exc
|
|
30
|
+
self._config = config
|
|
31
|
+
self._httpx = _httpx
|
|
32
|
+
self._client: httpx.AsyncClient = _httpx.AsyncClient(
|
|
33
|
+
timeout=config.timeout,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
async def send(self, event: RoomEvent, to: str) -> ProviderResult:
|
|
37
|
+
text = self._extract_text(event)
|
|
38
|
+
if not text:
|
|
39
|
+
return ProviderResult(success=False, error="empty_message")
|
|
40
|
+
|
|
41
|
+
payload = self._build_payload(event, to, text)
|
|
42
|
+
body = json.dumps(payload)
|
|
43
|
+
headers = self._build_headers(body)
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
resp = await self._client.post(
|
|
47
|
+
self._config.webhook_url,
|
|
48
|
+
content=body,
|
|
49
|
+
headers=headers,
|
|
50
|
+
)
|
|
51
|
+
resp.raise_for_status()
|
|
52
|
+
data: dict[str, Any] = resp.json()
|
|
53
|
+
except self._httpx.TimeoutException:
|
|
54
|
+
return ProviderResult(success=False, error="timeout")
|
|
55
|
+
except self._httpx.HTTPStatusError as exc:
|
|
56
|
+
return ProviderResult(
|
|
57
|
+
success=False,
|
|
58
|
+
error=f"http_{exc.response.status_code}",
|
|
59
|
+
)
|
|
60
|
+
except self._httpx.HTTPError as exc:
|
|
61
|
+
return ProviderResult(success=False, error=str(exc))
|
|
62
|
+
|
|
63
|
+
return self._parse_response(data)
|
|
64
|
+
|
|
65
|
+
def _build_payload(self, event: RoomEvent, to: str, text: str) -> dict[str, Any]:
|
|
66
|
+
return {
|
|
67
|
+
"recipient_id": to,
|
|
68
|
+
"channel_id": event.source.channel_id,
|
|
69
|
+
"room_id": event.room_id,
|
|
70
|
+
"content": {"type": "text", "body": text},
|
|
71
|
+
"metadata": event.metadata or {},
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
def _build_headers(self, body: str) -> dict[str, str]:
|
|
75
|
+
headers: dict[str, str] = {
|
|
76
|
+
"Content-Type": "application/json",
|
|
77
|
+
**self._config.headers,
|
|
78
|
+
}
|
|
79
|
+
if self._config.secret is not None:
|
|
80
|
+
signature = hmac.new(
|
|
81
|
+
self._config.secret.get_secret_value().encode(),
|
|
82
|
+
body.encode(),
|
|
83
|
+
hashlib.sha256,
|
|
84
|
+
).hexdigest()
|
|
85
|
+
headers["X-RoomKit-Signature"] = signature
|
|
86
|
+
return headers
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def _parse_response(data: dict[str, Any]) -> ProviderResult:
|
|
90
|
+
return ProviderResult(
|
|
91
|
+
success=True,
|
|
92
|
+
provider_message_id=data.get("message_id"),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
def _extract_text(event: RoomEvent) -> str:
|
|
97
|
+
content = event.content
|
|
98
|
+
if isinstance(content, TextContent):
|
|
99
|
+
return content.body
|
|
100
|
+
if hasattr(content, "body"):
|
|
101
|
+
return str(content.body)
|
|
102
|
+
return ""
|
|
103
|
+
|
|
104
|
+
async def close(self) -> None:
|
|
105
|
+
await self._client.aclose()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Inbound HTTP webhook parser."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from roomkit.models.delivery import InboundMessage
|
|
8
|
+
from roomkit.models.event import TextContent
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def parse_http_webhook(
|
|
12
|
+
payload: dict[str, Any],
|
|
13
|
+
channel_id: str,
|
|
14
|
+
) -> InboundMessage:
|
|
15
|
+
"""Convert a simple JSON body into an InboundMessage.
|
|
16
|
+
|
|
17
|
+
Expected payload shape::
|
|
18
|
+
|
|
19
|
+
{
|
|
20
|
+
"sender_id": "user-123",
|
|
21
|
+
"body": "Hello!",
|
|
22
|
+
"external_id": "msg-456", // optional
|
|
23
|
+
"metadata": {} // optional
|
|
24
|
+
}
|
|
25
|
+
"""
|
|
26
|
+
return InboundMessage(
|
|
27
|
+
channel_id=channel_id,
|
|
28
|
+
sender_id=payload["sender_id"],
|
|
29
|
+
content=TextContent(body=payload.get("body", "")),
|
|
30
|
+
external_id=payload.get("external_id"),
|
|
31
|
+
idempotency_key=payload.get("external_id"),
|
|
32
|
+
metadata=payload.get("metadata", {}),
|
|
33
|
+
)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Facebook Messenger provider."""
|
|
2
|
+
|
|
3
|
+
from roomkit.providers.messenger.base import MessengerProvider
|
|
4
|
+
from roomkit.providers.messenger.config import MessengerConfig
|
|
5
|
+
from roomkit.providers.messenger.facebook import FacebookMessengerProvider
|
|
6
|
+
from roomkit.providers.messenger.mock import MockMessengerProvider
|
|
7
|
+
from roomkit.providers.messenger.webhook import parse_messenger_webhook
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"FacebookMessengerProvider",
|
|
11
|
+
"MessengerConfig",
|
|
12
|
+
"MessengerProvider",
|
|
13
|
+
"MockMessengerProvider",
|
|
14
|
+
"parse_messenger_webhook",
|
|
15
|
+
]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Abstract base class for Messenger providers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
|
|
7
|
+
from roomkit.models.delivery import ProviderResult
|
|
8
|
+
from roomkit.models.event import RoomEvent
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MessengerProvider(ABC):
|
|
12
|
+
"""Facebook Messenger delivery provider."""
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def name(self) -> str:
|
|
16
|
+
"""Provider name."""
|
|
17
|
+
return self.__class__.__name__
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
async def send(self, event: RoomEvent, to: str) -> ProviderResult:
|
|
21
|
+
"""Send a Facebook Messenger message.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
event: The room event containing the message content.
|
|
25
|
+
to: Recipient Messenger user ID.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Result with provider-specific delivery metadata.
|
|
29
|
+
"""
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
async def close(self) -> None: # noqa: B027
|
|
33
|
+
"""Release resources. Override in subclasses that hold connections."""
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Facebook Messenger provider configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, SecretStr
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MessengerConfig(BaseModel):
|
|
9
|
+
"""Facebook Messenger provider configuration."""
|
|
10
|
+
|
|
11
|
+
page_access_token: SecretStr
|
|
12
|
+
api_version: str = "v21.0"
|
|
13
|
+
timeout: float = 30.0
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def base_url(self) -> str:
|
|
17
|
+
return f"https://graph.facebook.com/{self.api_version}/me/messages"
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Facebook Messenger provider — sends messages via the Messenger Platform Send API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from roomkit.models.delivery import ProviderResult
|
|
8
|
+
from roomkit.models.event import RoomEvent, TextContent
|
|
9
|
+
from roomkit.providers.messenger.base import MessengerProvider
|
|
10
|
+
from roomkit.providers.messenger.config import MessengerConfig
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FacebookMessengerProvider(MessengerProvider):
|
|
17
|
+
"""Send messages via the Facebook Messenger Platform Send API."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, config: MessengerConfig) -> None:
|
|
20
|
+
try:
|
|
21
|
+
import httpx as _httpx
|
|
22
|
+
except ImportError as exc:
|
|
23
|
+
raise ImportError(
|
|
24
|
+
"httpx is required for FacebookMessengerProvider. "
|
|
25
|
+
"Install it with: pip install roomkit[httpx]"
|
|
26
|
+
) from exc
|
|
27
|
+
self._config = config
|
|
28
|
+
self._httpx = _httpx
|
|
29
|
+
self._client: httpx.AsyncClient = _httpx.AsyncClient(
|
|
30
|
+
timeout=config.timeout,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
async def send(self, event: RoomEvent, to: str) -> ProviderResult:
|
|
34
|
+
text = self._extract_text(event)
|
|
35
|
+
if not text:
|
|
36
|
+
return ProviderResult(success=False, error="empty_message")
|
|
37
|
+
|
|
38
|
+
payload: dict[str, Any] = {
|
|
39
|
+
"recipient": {"id": to},
|
|
40
|
+
"messaging_type": "RESPONSE",
|
|
41
|
+
"message": {"text": text},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
resp = await self._client.post(
|
|
46
|
+
self._config.base_url,
|
|
47
|
+
json=payload,
|
|
48
|
+
params={
|
|
49
|
+
"access_token": self._config.page_access_token.get_secret_value(),
|
|
50
|
+
},
|
|
51
|
+
)
|
|
52
|
+
resp.raise_for_status()
|
|
53
|
+
data = resp.json()
|
|
54
|
+
except self._httpx.TimeoutException:
|
|
55
|
+
return ProviderResult(success=False, error="timeout")
|
|
56
|
+
except self._httpx.HTTPStatusError as exc:
|
|
57
|
+
return self._parse_error(exc)
|
|
58
|
+
except self._httpx.HTTPError as exc:
|
|
59
|
+
return ProviderResult(success=False, error=str(exc))
|
|
60
|
+
|
|
61
|
+
return ProviderResult(
|
|
62
|
+
success=True,
|
|
63
|
+
provider_message_id=data.get("message_id"),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def _parse_error(exc: Any) -> ProviderResult:
|
|
68
|
+
"""Extract a Facebook Graph API error message when available."""
|
|
69
|
+
try:
|
|
70
|
+
body = exc.response.json()
|
|
71
|
+
error = body.get("error", {})
|
|
72
|
+
code = error.get("code", exc.response.status_code)
|
|
73
|
+
message = error.get("message", "")
|
|
74
|
+
return ProviderResult(
|
|
75
|
+
success=False,
|
|
76
|
+
error=f"graph_{code}",
|
|
77
|
+
metadata={"message": message},
|
|
78
|
+
)
|
|
79
|
+
except Exception:
|
|
80
|
+
return ProviderResult(
|
|
81
|
+
success=False,
|
|
82
|
+
error=f"http_{exc.response.status_code}",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
def _extract_text(event: RoomEvent) -> str:
|
|
87
|
+
content = event.content
|
|
88
|
+
if isinstance(content, TextContent):
|
|
89
|
+
return content.body
|
|
90
|
+
if hasattr(content, "body"):
|
|
91
|
+
return str(content.body)
|
|
92
|
+
return ""
|
|
93
|
+
|
|
94
|
+
async def close(self) -> None:
|
|
95
|
+
await self._client.aclose()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Mock Messenger provider for testing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
from roomkit.models.delivery import ProviderResult
|
|
9
|
+
from roomkit.models.event import RoomEvent
|
|
10
|
+
from roomkit.providers.messenger.base import MessengerProvider
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MockMessengerProvider(MessengerProvider):
|
|
14
|
+
"""Records sent messages for verification in tests."""
|
|
15
|
+
|
|
16
|
+
def __init__(self) -> None:
|
|
17
|
+
self.sent: list[dict[str, Any]] = []
|
|
18
|
+
|
|
19
|
+
async def send(self, event: RoomEvent, to: str) -> ProviderResult:
|
|
20
|
+
self.sent.append({"event": event, "to": to})
|
|
21
|
+
return ProviderResult(success=True, provider_message_id=uuid4().hex)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Facebook Messenger webhook parsing helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from roomkit.models.delivery import InboundMessage
|
|
8
|
+
from roomkit.models.event import TextContent
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def parse_messenger_webhook(
|
|
12
|
+
payload: dict[str, Any],
|
|
13
|
+
channel_id: str,
|
|
14
|
+
) -> list[InboundMessage]:
|
|
15
|
+
"""Convert a Facebook Messenger webhook payload into InboundMessages.
|
|
16
|
+
|
|
17
|
+
Facebook sends batches of events under ``payload["entry"]``. Each entry
|
|
18
|
+
contains a ``messaging`` list with individual messages. Only messages that
|
|
19
|
+
carry a ``message.text`` field are returned; delivery/read receipts and
|
|
20
|
+
postbacks are silently skipped.
|
|
21
|
+
"""
|
|
22
|
+
messages: list[InboundMessage] = []
|
|
23
|
+
for entry in payload.get("entry", []):
|
|
24
|
+
for event in entry.get("messaging", []):
|
|
25
|
+
msg = event.get("message")
|
|
26
|
+
if msg is None or "text" not in msg:
|
|
27
|
+
continue
|
|
28
|
+
sender_id = event.get("sender", {}).get("id", "")
|
|
29
|
+
messages.append(
|
|
30
|
+
InboundMessage(
|
|
31
|
+
channel_id=channel_id,
|
|
32
|
+
sender_id=sender_id,
|
|
33
|
+
content=TextContent(body=msg["text"]),
|
|
34
|
+
external_id=msg.get("mid"),
|
|
35
|
+
idempotency_key=msg.get("mid"),
|
|
36
|
+
metadata={
|
|
37
|
+
"recipient_id": event.get("recipient", {}).get("id", ""),
|
|
38
|
+
"timestamp": event.get("timestamp", 0),
|
|
39
|
+
},
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
return messages
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""OpenAI AI provider — generates responses via the OpenAI Chat Completions API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from roomkit.providers.ai.base import (
|
|
9
|
+
AIContext,
|
|
10
|
+
AIImagePart,
|
|
11
|
+
AIProvider,
|
|
12
|
+
AIResponse,
|
|
13
|
+
AITextPart,
|
|
14
|
+
AIToolCall,
|
|
15
|
+
ProviderError,
|
|
16
|
+
)
|
|
17
|
+
from roomkit.providers.openai.config import OpenAIConfig
|
|
18
|
+
|
|
19
|
+
# OpenAI models that support vision
|
|
20
|
+
_VISION_MODELS = (
|
|
21
|
+
"gpt-4o",
|
|
22
|
+
"gpt-4-turbo",
|
|
23
|
+
"gpt-4-vision",
|
|
24
|
+
"o1",
|
|
25
|
+
"o3",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class OpenAIAIProvider(AIProvider):
|
|
30
|
+
"""AI provider using the OpenAI Chat Completions API."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, config: OpenAIConfig) -> None:
|
|
33
|
+
try:
|
|
34
|
+
import openai as _openai
|
|
35
|
+
except ImportError as exc:
|
|
36
|
+
raise ImportError(
|
|
37
|
+
"openai is required for OpenAIAIProvider. "
|
|
38
|
+
"Install it with: pip install roomkit[openai]"
|
|
39
|
+
) from exc
|
|
40
|
+
self._config = config
|
|
41
|
+
self._api_status_error = _openai.APIStatusError
|
|
42
|
+
self._client = _openai.AsyncOpenAI(
|
|
43
|
+
api_key=config.api_key.get_secret_value(),
|
|
44
|
+
base_url=config.base_url,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def model_name(self) -> str:
|
|
49
|
+
return self._config.model
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def supports_vision(self) -> bool:
|
|
53
|
+
"""GPT-4o and GPT-4-turbo models support vision."""
|
|
54
|
+
return any(self._config.model.startswith(prefix) for prefix in _VISION_MODELS)
|
|
55
|
+
|
|
56
|
+
def _format_content(
|
|
57
|
+
self, content: str | list[AITextPart | AIImagePart]
|
|
58
|
+
) -> str | list[dict[str, Any]]:
|
|
59
|
+
"""Format message content for OpenAI API.
|
|
60
|
+
|
|
61
|
+
Converts AITextPart/AIImagePart to OpenAI's content block format.
|
|
62
|
+
"""
|
|
63
|
+
if isinstance(content, str):
|
|
64
|
+
return content
|
|
65
|
+
|
|
66
|
+
parts: list[dict[str, Any]] = []
|
|
67
|
+
for part in content:
|
|
68
|
+
if isinstance(part, AITextPart):
|
|
69
|
+
parts.append({"type": "text", "text": part.text})
|
|
70
|
+
elif isinstance(part, AIImagePart):
|
|
71
|
+
parts.append(
|
|
72
|
+
{
|
|
73
|
+
"type": "image_url",
|
|
74
|
+
"image_url": {"url": part.url},
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
return parts
|
|
78
|
+
|
|
79
|
+
async def generate(self, context: AIContext) -> AIResponse:
|
|
80
|
+
messages: list[dict[str, Any]] = []
|
|
81
|
+
if context.system_prompt:
|
|
82
|
+
messages.append({"role": "system", "content": context.system_prompt})
|
|
83
|
+
messages.extend(
|
|
84
|
+
{"role": m.role, "content": self._format_content(m.content)} for m in context.messages
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
kwargs: dict[str, Any] = {
|
|
88
|
+
"model": self._config.model,
|
|
89
|
+
"max_tokens": context.max_tokens or self._config.max_tokens,
|
|
90
|
+
"messages": messages,
|
|
91
|
+
}
|
|
92
|
+
if context.temperature is not None:
|
|
93
|
+
kwargs["temperature"] = context.temperature
|
|
94
|
+
|
|
95
|
+
# Add tools if provided
|
|
96
|
+
if context.tools:
|
|
97
|
+
kwargs["tools"] = [
|
|
98
|
+
{
|
|
99
|
+
"type": "function",
|
|
100
|
+
"function": {
|
|
101
|
+
"name": t.name,
|
|
102
|
+
"description": t.description,
|
|
103
|
+
"parameters": t.parameters,
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
for t in context.tools
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
response = await self._client.chat.completions.create(**kwargs)
|
|
111
|
+
except ProviderError:
|
|
112
|
+
raise
|
|
113
|
+
except self._api_status_error as exc:
|
|
114
|
+
retryable = exc.status_code in (429, 500, 502, 503)
|
|
115
|
+
raise ProviderError(
|
|
116
|
+
str(exc),
|
|
117
|
+
retryable=retryable,
|
|
118
|
+
provider="openai",
|
|
119
|
+
status_code=exc.status_code,
|
|
120
|
+
) from exc
|
|
121
|
+
except Exception as exc:
|
|
122
|
+
raise ProviderError(
|
|
123
|
+
str(exc),
|
|
124
|
+
retryable=False,
|
|
125
|
+
provider="openai",
|
|
126
|
+
status_code=None,
|
|
127
|
+
) from exc
|
|
128
|
+
|
|
129
|
+
choice = response.choices[0]
|
|
130
|
+
usage: dict[str, int] = {}
|
|
131
|
+
if response.usage:
|
|
132
|
+
usage = {
|
|
133
|
+
"prompt_tokens": response.usage.prompt_tokens,
|
|
134
|
+
"completion_tokens": response.usage.completion_tokens,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
# Extract tool calls from response
|
|
138
|
+
tool_calls: list[AIToolCall] = []
|
|
139
|
+
if choice.message.tool_calls:
|
|
140
|
+
for tc in choice.message.tool_calls:
|
|
141
|
+
tool_calls.append(
|
|
142
|
+
AIToolCall(
|
|
143
|
+
id=tc.id,
|
|
144
|
+
name=tc.function.name,
|
|
145
|
+
arguments=json.loads(tc.function.arguments),
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
return AIResponse(
|
|
150
|
+
content=choice.message.content or "",
|
|
151
|
+
finish_reason=choice.finish_reason,
|
|
152
|
+
usage=usage,
|
|
153
|
+
metadata={"model": response.model},
|
|
154
|
+
tool_calls=tool_calls,
|
|
155
|
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""OpenAI provider configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, SecretStr
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OpenAIConfig(BaseModel):
|
|
9
|
+
"""OpenAI AI provider configuration.
|
|
10
|
+
|
|
11
|
+
Attributes:
|
|
12
|
+
api_key: API key for authentication.
|
|
13
|
+
base_url: Custom base URL for OpenAI-compatible APIs (e.g., Ollama, LM Studio,
|
|
14
|
+
Azure OpenAI, or other providers). If None, uses the default OpenAI API.
|
|
15
|
+
model: Model identifier to use.
|
|
16
|
+
max_tokens: Maximum tokens in the response.
|
|
17
|
+
temperature: Sampling temperature.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
api_key: SecretStr
|
|
21
|
+
base_url: str | None = None
|
|
22
|
+
model: str = "gpt-4o"
|
|
23
|
+
max_tokens: int = 1024
|
|
24
|
+
temperature: float = 0.7
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Pydantic AI provider configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, SecretStr
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PydanticAIConfig(BaseModel):
|
|
9
|
+
"""Pydantic AI provider configuration."""
|
|
10
|
+
|
|
11
|
+
model: str = "openai:gpt-4o"
|
|
12
|
+
api_key: SecretStr | None = None
|
|
13
|
+
temperature: float = 0.7
|
|
14
|
+
max_tokens: int = 1024
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Abstract base class for RCS providers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from roomkit.models.delivery import InboundMessage, ProviderResult
|
|
9
|
+
from roomkit.models.event import RoomEvent
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RCSDeliveryResult(ProviderResult):
|
|
13
|
+
"""Extended result for RCS delivery with fallback information."""
|
|
14
|
+
|
|
15
|
+
channel_used: str = "rcs" # "rcs" or "sms" if fallback occurred
|
|
16
|
+
fallback: bool = False
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RCSProvider(ABC):
|
|
20
|
+
"""RCS delivery provider for rich communication services."""
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def name(self) -> str:
|
|
24
|
+
"""Provider name (e.g. 'twilio', 'sinch')."""
|
|
25
|
+
return self.__class__.__name__
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def sender_id(self) -> str:
|
|
30
|
+
"""RCS sender/agent identifier."""
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def supports_fallback(self) -> bool:
|
|
35
|
+
"""Whether this provider supports automatic SMS fallback."""
|
|
36
|
+
return True
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
async def send(
|
|
40
|
+
self,
|
|
41
|
+
event: RoomEvent,
|
|
42
|
+
to: str,
|
|
43
|
+
*,
|
|
44
|
+
fallback: bool = True,
|
|
45
|
+
) -> RCSDeliveryResult:
|
|
46
|
+
"""Send an RCS message.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
event: The room event containing the message content.
|
|
50
|
+
to: Recipient phone number (E.164 format).
|
|
51
|
+
fallback: If True, allow fallback to SMS when RCS unavailable.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Result with delivery info including whether fallback occurred.
|
|
55
|
+
"""
|
|
56
|
+
...
|
|
57
|
+
|
|
58
|
+
async def check_capability(self, phone_number: str) -> bool:
|
|
59
|
+
"""Check if a phone number supports RCS.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
phone_number: Phone number to check (E.164 format).
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
True if the number supports RCS, False otherwise.
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
NotImplementedError: If the provider doesn't support capability check.
|
|
69
|
+
"""
|
|
70
|
+
raise NotImplementedError(f"{self.name} does not support RCS capability checking")
|
|
71
|
+
|
|
72
|
+
async def parse_webhook(self, payload: dict[str, Any]) -> InboundMessage:
|
|
73
|
+
"""Parse an inbound webhook payload into an InboundMessage."""
|
|
74
|
+
raise NotImplementedError
|
|
75
|
+
|
|
76
|
+
def verify_signature(
|
|
77
|
+
self,
|
|
78
|
+
payload: bytes,
|
|
79
|
+
signature: str,
|
|
80
|
+
timestamp: str | None = None,
|
|
81
|
+
) -> bool:
|
|
82
|
+
"""Verify that a webhook payload was signed by the provider.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
payload: Raw request body bytes.
|
|
86
|
+
signature: Signature header value from the webhook request.
|
|
87
|
+
timestamp: Timestamp header value (required by some providers).
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
True if the signature is valid, False otherwise.
|
|
91
|
+
"""
|
|
92
|
+
raise NotImplementedError(f"{self.name} does not support webhook signature verification")
|
|
93
|
+
|
|
94
|
+
async def close(self) -> None: # noqa: B027
|
|
95
|
+
"""Release resources. Override in subclasses that hold connections."""
|