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.
Files changed (114) hide show
  1. roomkit/AGENTS.md +362 -0
  2. roomkit/__init__.py +372 -0
  3. roomkit/_version.py +1 -0
  4. roomkit/ai_docs.py +93 -0
  5. roomkit/channels/__init__.py +194 -0
  6. roomkit/channels/ai.py +238 -0
  7. roomkit/channels/base.py +66 -0
  8. roomkit/channels/transport.py +115 -0
  9. roomkit/channels/websocket.py +85 -0
  10. roomkit/core/__init__.py +0 -0
  11. roomkit/core/_channel_ops.py +252 -0
  12. roomkit/core/_helpers.py +296 -0
  13. roomkit/core/_inbound.py +435 -0
  14. roomkit/core/_room_lifecycle.py +275 -0
  15. roomkit/core/circuit_breaker.py +84 -0
  16. roomkit/core/event_router.py +401 -0
  17. roomkit/core/framework.py +793 -0
  18. roomkit/core/hooks.py +232 -0
  19. roomkit/core/inbound_router.py +57 -0
  20. roomkit/core/locks.py +66 -0
  21. roomkit/core/rate_limiter.py +67 -0
  22. roomkit/core/retry.py +49 -0
  23. roomkit/core/router.py +24 -0
  24. roomkit/core/transcoder.py +85 -0
  25. roomkit/identity/__init__.py +0 -0
  26. roomkit/identity/base.py +27 -0
  27. roomkit/identity/mock.py +49 -0
  28. roomkit/llms.txt +52 -0
  29. roomkit/models/__init__.py +104 -0
  30. roomkit/models/channel.py +99 -0
  31. roomkit/models/context.py +35 -0
  32. roomkit/models/delivery.py +76 -0
  33. roomkit/models/enums.py +170 -0
  34. roomkit/models/event.py +203 -0
  35. roomkit/models/framework_event.py +19 -0
  36. roomkit/models/hook.py +68 -0
  37. roomkit/models/identity.py +81 -0
  38. roomkit/models/participant.py +34 -0
  39. roomkit/models/room.py +33 -0
  40. roomkit/models/task.py +36 -0
  41. roomkit/providers/__init__.py +0 -0
  42. roomkit/providers/ai/__init__.py +0 -0
  43. roomkit/providers/ai/base.py +140 -0
  44. roomkit/providers/ai/mock.py +33 -0
  45. roomkit/providers/anthropic/__init__.py +6 -0
  46. roomkit/providers/anthropic/ai.py +145 -0
  47. roomkit/providers/anthropic/config.py +14 -0
  48. roomkit/providers/elasticemail/__init__.py +6 -0
  49. roomkit/providers/elasticemail/config.py +16 -0
  50. roomkit/providers/elasticemail/email.py +97 -0
  51. roomkit/providers/email/__init__.py +0 -0
  52. roomkit/providers/email/base.py +46 -0
  53. roomkit/providers/email/mock.py +34 -0
  54. roomkit/providers/gemini/__init__.py +6 -0
  55. roomkit/providers/gemini/ai.py +153 -0
  56. roomkit/providers/gemini/config.py +14 -0
  57. roomkit/providers/http/__init__.py +15 -0
  58. roomkit/providers/http/base.py +33 -0
  59. roomkit/providers/http/config.py +14 -0
  60. roomkit/providers/http/mock.py +21 -0
  61. roomkit/providers/http/provider.py +105 -0
  62. roomkit/providers/http/webhook.py +33 -0
  63. roomkit/providers/messenger/__init__.py +15 -0
  64. roomkit/providers/messenger/base.py +33 -0
  65. roomkit/providers/messenger/config.py +17 -0
  66. roomkit/providers/messenger/facebook.py +95 -0
  67. roomkit/providers/messenger/mock.py +21 -0
  68. roomkit/providers/messenger/webhook.py +42 -0
  69. roomkit/providers/openai/__init__.py +6 -0
  70. roomkit/providers/openai/ai.py +155 -0
  71. roomkit/providers/openai/config.py +24 -0
  72. roomkit/providers/pydantic_ai/__init__.py +5 -0
  73. roomkit/providers/pydantic_ai/config.py +14 -0
  74. roomkit/providers/rcs/__init__.py +9 -0
  75. roomkit/providers/rcs/base.py +95 -0
  76. roomkit/providers/rcs/mock.py +78 -0
  77. roomkit/providers/sendgrid/__init__.py +5 -0
  78. roomkit/providers/sendgrid/config.py +13 -0
  79. roomkit/providers/sinch/__init__.py +6 -0
  80. roomkit/providers/sinch/config.py +22 -0
  81. roomkit/providers/sinch/sms.py +192 -0
  82. roomkit/providers/sms/__init__.py +15 -0
  83. roomkit/providers/sms/base.py +67 -0
  84. roomkit/providers/sms/meta.py +401 -0
  85. roomkit/providers/sms/mock.py +24 -0
  86. roomkit/providers/sms/phone.py +77 -0
  87. roomkit/providers/telnyx/__init__.py +21 -0
  88. roomkit/providers/telnyx/config.py +14 -0
  89. roomkit/providers/telnyx/rcs.py +352 -0
  90. roomkit/providers/telnyx/sms.py +231 -0
  91. roomkit/providers/twilio/__init__.py +18 -0
  92. roomkit/providers/twilio/config.py +19 -0
  93. roomkit/providers/twilio/rcs.py +183 -0
  94. roomkit/providers/twilio/sms.py +200 -0
  95. roomkit/providers/voicemeup/__init__.py +15 -0
  96. roomkit/providers/voicemeup/config.py +21 -0
  97. roomkit/providers/voicemeup/sms.py +374 -0
  98. roomkit/providers/whatsapp/__init__.py +0 -0
  99. roomkit/providers/whatsapp/base.py +44 -0
  100. roomkit/providers/whatsapp/mock.py +21 -0
  101. roomkit/py.typed +0 -0
  102. roomkit/realtime/__init__.py +17 -0
  103. roomkit/realtime/base.py +111 -0
  104. roomkit/realtime/memory.py +158 -0
  105. roomkit/sources/__init__.py +35 -0
  106. roomkit/sources/base.py +207 -0
  107. roomkit/sources/websocket.py +260 -0
  108. roomkit/store/__init__.py +0 -0
  109. roomkit/store/base.py +230 -0
  110. roomkit/store/memory.py +293 -0
  111. roomkit-0.1.0.dist-info/METADATA +567 -0
  112. roomkit-0.1.0.dist-info/RECORD +114 -0
  113. roomkit-0.1.0.dist-info/WHEEL +4 -0
  114. 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,6 @@
1
+ """OpenAI provider."""
2
+
3
+ from roomkit.providers.openai.ai import OpenAIAIProvider
4
+ from roomkit.providers.openai.config import OpenAIConfig
5
+
6
+ __all__ = ["OpenAIAIProvider", "OpenAIConfig"]
@@ -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,5 @@
1
+ """Pydantic AI provider."""
2
+
3
+ from roomkit.providers.pydantic_ai.config import PydanticAIConfig
4
+
5
+ __all__ = ["PydanticAIConfig"]
@@ -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,9 @@
1
+ """RCS providers for rich communication services."""
2
+
3
+ from roomkit.providers.rcs.base import RCSProvider
4
+ from roomkit.providers.rcs.mock import MockRCSProvider
5
+
6
+ __all__ = [
7
+ "RCSProvider",
8
+ "MockRCSProvider",
9
+ ]
@@ -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."""