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,145 @@
1
+ """Anthropic AI provider — generates responses via the Anthropic Messages API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from roomkit.providers.ai.base import (
8
+ AIContext,
9
+ AIImagePart,
10
+ AIProvider,
11
+ AIResponse,
12
+ AITextPart,
13
+ AIToolCall,
14
+ ProviderError,
15
+ )
16
+ from roomkit.providers.anthropic.config import AnthropicConfig
17
+
18
+ # Claude models that support vision (Claude 3 and later)
19
+ _VISION_MODELS = (
20
+ "claude-3",
21
+ "claude-sonnet-4",
22
+ "claude-opus-4",
23
+ )
24
+
25
+
26
+ class AnthropicAIProvider(AIProvider):
27
+ """AI provider using the Anthropic Messages API."""
28
+
29
+ def __init__(self, config: AnthropicConfig) -> None:
30
+ try:
31
+ import anthropic as _anthropic
32
+ except ImportError as exc:
33
+ raise ImportError(
34
+ "anthropic is required for AnthropicAIProvider. "
35
+ "Install it with: pip install roomkit[anthropic]"
36
+ ) from exc
37
+ self._config = config
38
+ self._api_status_error = _anthropic.APIStatusError
39
+ self._client = _anthropic.AsyncAnthropic(
40
+ api_key=config.api_key.get_secret_value(),
41
+ )
42
+
43
+ @property
44
+ def model_name(self) -> str:
45
+ return self._config.model
46
+
47
+ @property
48
+ def supports_vision(self) -> bool:
49
+ """Claude 3+ models support vision."""
50
+ return any(self._config.model.startswith(prefix) for prefix in _VISION_MODELS)
51
+
52
+ def _format_content(
53
+ self, content: str | list[AITextPart | AIImagePart]
54
+ ) -> str | list[dict[str, Any]]:
55
+ """Format message content for Anthropic API.
56
+
57
+ Converts AITextPart/AIImagePart to Anthropic's content block format.
58
+ """
59
+ if isinstance(content, str):
60
+ return content
61
+
62
+ parts: list[dict[str, Any]] = []
63
+ for part in content:
64
+ if isinstance(part, AITextPart):
65
+ parts.append({"type": "text", "text": part.text})
66
+ elif isinstance(part, AIImagePart):
67
+ parts.append(
68
+ {
69
+ "type": "image",
70
+ "source": {
71
+ "type": "url",
72
+ "url": part.url,
73
+ },
74
+ }
75
+ )
76
+ return parts
77
+
78
+ async def generate(self, context: AIContext) -> AIResponse:
79
+ messages: list[dict[str, Any]] = [
80
+ {"role": m.role, "content": self._format_content(m.content)} for m in context.messages
81
+ ]
82
+ kwargs: dict[str, Any] = {
83
+ "model": self._config.model,
84
+ "max_tokens": context.max_tokens or self._config.max_tokens,
85
+ "messages": messages,
86
+ }
87
+ if context.system_prompt:
88
+ kwargs["system"] = context.system_prompt
89
+ if context.temperature is not None:
90
+ kwargs["temperature"] = context.temperature
91
+
92
+ # Add tools if provided
93
+ if context.tools:
94
+ kwargs["tools"] = [
95
+ {
96
+ "name": t.name,
97
+ "description": t.description,
98
+ "input_schema": t.parameters,
99
+ }
100
+ for t in context.tools
101
+ ]
102
+
103
+ try:
104
+ response = await self._client.messages.create(**kwargs)
105
+ except self._api_status_error as exc:
106
+ retryable = exc.status_code in (429, 500, 502, 503, 529)
107
+ raise ProviderError(
108
+ str(exc),
109
+ retryable=retryable,
110
+ provider="anthropic",
111
+ status_code=exc.status_code,
112
+ ) from exc
113
+ except Exception as exc:
114
+ raise ProviderError(
115
+ str(exc),
116
+ retryable=False,
117
+ provider="anthropic",
118
+ status_code=None,
119
+ ) from exc
120
+
121
+ # Extract text content and tool calls from response
122
+ text_content = ""
123
+ tool_calls: list[AIToolCall] = []
124
+ for block in response.content:
125
+ if block.type == "text":
126
+ text_content = block.text
127
+ elif block.type == "tool_use":
128
+ tool_calls.append(
129
+ AIToolCall(
130
+ id=block.id,
131
+ name=block.name,
132
+ arguments=block.input,
133
+ )
134
+ )
135
+
136
+ return AIResponse(
137
+ content=text_content,
138
+ finish_reason=response.stop_reason,
139
+ usage={
140
+ "input_tokens": response.usage.input_tokens,
141
+ "output_tokens": response.usage.output_tokens,
142
+ },
143
+ metadata={"model": response.model},
144
+ tool_calls=tool_calls,
145
+ )
@@ -0,0 +1,14 @@
1
+ """Anthropic provider configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import BaseModel, SecretStr
6
+
7
+
8
+ class AnthropicConfig(BaseModel):
9
+ """Anthropic AI provider configuration."""
10
+
11
+ api_key: SecretStr
12
+ model: str = "claude-sonnet-4-20250514"
13
+ max_tokens: int = 1024
14
+ temperature: float = 0.7
@@ -0,0 +1,6 @@
1
+ """Elastic Email provider."""
2
+
3
+ from roomkit.providers.elasticemail.config import ElasticEmailConfig
4
+ from roomkit.providers.elasticemail.email import ElasticEmailProvider
5
+
6
+ __all__ = ["ElasticEmailConfig", "ElasticEmailProvider"]
@@ -0,0 +1,16 @@
1
+ """Elastic Email provider configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import BaseModel, SecretStr
6
+
7
+
8
+ class ElasticEmailConfig(BaseModel):
9
+ """Elastic Email provider configuration."""
10
+
11
+ api_key: SecretStr
12
+ from_email: str
13
+ from_name: str | None = None
14
+ is_transactional: bool = True
15
+ base_url: str = "https://api.elasticemail.com/v2/email/send"
16
+ timeout: float = 30.0
@@ -0,0 +1,97 @@
1
+ """Elastic Email provider — sends emails via the Elastic Email v2 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 RichContent, RoomEvent, TextContent
9
+ from roomkit.providers.elasticemail.config import ElasticEmailConfig
10
+ from roomkit.providers.email.base import EmailProvider
11
+
12
+ if TYPE_CHECKING:
13
+ import httpx
14
+
15
+
16
+ class ElasticEmailProvider(EmailProvider):
17
+ """Send-only email provider using the Elastic Email REST API."""
18
+
19
+ def __init__(self, config: ElasticEmailConfig) -> None:
20
+ try:
21
+ import httpx as _httpx
22
+ except ImportError as exc:
23
+ raise ImportError(
24
+ "httpx is required for ElasticEmailProvider. "
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(
34
+ self,
35
+ event: RoomEvent,
36
+ to: str,
37
+ from_: str | None = None,
38
+ subject: str | None = None,
39
+ ) -> ProviderResult:
40
+ body_text, body_html = self._extract_body(event)
41
+ if not body_text and not body_html:
42
+ return ProviderResult(success=False, error="empty_message")
43
+
44
+ data: dict[str, Any] = {
45
+ "apikey": self._config.api_key.get_secret_value(),
46
+ "from": from_ or self._config.from_email,
47
+ "to": to,
48
+ "subject": subject or "",
49
+ "bodyText": body_text,
50
+ "bodyHtml": body_html,
51
+ "isTransactional": str(self._config.is_transactional).lower(),
52
+ }
53
+ if self._config.from_name:
54
+ data["fromName"] = self._config.from_name
55
+
56
+ try:
57
+ resp = await self._client.post(self._config.base_url, data=data)
58
+ resp.raise_for_status()
59
+ result = resp.json()
60
+ except self._httpx.TimeoutException:
61
+ return ProviderResult(success=False, error="timeout")
62
+ except self._httpx.HTTPStatusError as exc:
63
+ return ProviderResult(
64
+ success=False,
65
+ error=f"http_{exc.response.status_code}",
66
+ )
67
+ except self._httpx.HTTPError as exc:
68
+ return ProviderResult(success=False, error=str(exc))
69
+
70
+ return self._parse_response(result)
71
+
72
+ @staticmethod
73
+ def _parse_response(data: dict[str, Any]) -> ProviderResult:
74
+ if data.get("success"):
75
+ return ProviderResult(
76
+ success=True,
77
+ provider_message_id=data.get("data", {}).get("transactionid"),
78
+ )
79
+ return ProviderResult(
80
+ success=False,
81
+ error=data.get("error", "unknown_error"),
82
+ )
83
+
84
+ @staticmethod
85
+ def _extract_body(event: RoomEvent) -> tuple[str, str]:
86
+ """Return ``(body_text, body_html)`` from the event content."""
87
+ content = event.content
88
+ if isinstance(content, RichContent):
89
+ return "", content.body
90
+ if isinstance(content, TextContent):
91
+ return content.body, ""
92
+ if hasattr(content, "body"):
93
+ return str(content.body), ""
94
+ return "", ""
95
+
96
+ async def close(self) -> None:
97
+ await self._client.aclose()
File without changes
@@ -0,0 +1,46 @@
1
+ """Abstract base class for email 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 EmailProvider(ABC):
13
+ """Email delivery provider."""
14
+
15
+ @property
16
+ def name(self) -> str:
17
+ """Provider name (e.g. 'sendgrid', 'ses')."""
18
+ return self.__class__.__name__
19
+
20
+ @abstractmethod
21
+ async def send(
22
+ self,
23
+ event: RoomEvent,
24
+ to: str,
25
+ from_: str | None = None,
26
+ subject: str | None = None,
27
+ ) -> ProviderResult:
28
+ """Send an email message.
29
+
30
+ Args:
31
+ event: The room event containing the message content.
32
+ to: Recipient email address.
33
+ from_: Sender email address override.
34
+ subject: Email subject line.
35
+
36
+ Returns:
37
+ Result with provider-specific delivery metadata.
38
+ """
39
+ ...
40
+
41
+ async def parse_inbound(self, payload: dict[str, Any]) -> InboundMessage:
42
+ """Parse an inbound email payload into an InboundMessage."""
43
+ raise NotImplementedError
44
+
45
+ async def close(self) -> None: # noqa: B027
46
+ """Release resources. Override in subclasses that hold connections."""
@@ -0,0 +1,34 @@
1
+ """Mock email 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.email.base import EmailProvider
11
+
12
+
13
+ class MockEmailProvider(EmailProvider):
14
+ """Records sent emails for verification in tests."""
15
+
16
+ def __init__(self) -> None:
17
+ self.sent: list[dict[str, Any]] = []
18
+
19
+ async def send(
20
+ self,
21
+ event: RoomEvent,
22
+ to: str,
23
+ from_: str | None = None,
24
+ subject: str | None = None,
25
+ ) -> ProviderResult:
26
+ self.sent.append(
27
+ {
28
+ "event": event,
29
+ "to": to,
30
+ "from": from_ or "",
31
+ "subject": subject or "",
32
+ }
33
+ )
34
+ return ProviderResult(success=True, provider_message_id=uuid4().hex)
@@ -0,0 +1,6 @@
1
+ """Google Gemini provider."""
2
+
3
+ from roomkit.providers.gemini.ai import GeminiAIProvider
4
+ from roomkit.providers.gemini.config import GeminiConfig
5
+
6
+ __all__ = ["GeminiAIProvider", "GeminiConfig"]
@@ -0,0 +1,153 @@
1
+ """Google Gemini AI provider — generates responses via the Google Generative AI API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Any, cast
7
+
8
+ from roomkit.providers.ai.base import (
9
+ AIContext,
10
+ AIImagePart,
11
+ AIMessage,
12
+ AIProvider,
13
+ AIResponse,
14
+ AITextPart,
15
+ AIToolCall,
16
+ ProviderError,
17
+ )
18
+ from roomkit.providers.gemini.config import GeminiConfig
19
+
20
+
21
+ class GeminiAIProvider(AIProvider):
22
+ """AI provider using the Google Gemini API."""
23
+
24
+ def __init__(self, config: GeminiConfig) -> None:
25
+ try:
26
+ from google import genai as _genai
27
+ from google.genai import types as _types
28
+ except ImportError as exc:
29
+ raise ImportError(
30
+ "google-genai is required for GeminiAIProvider. "
31
+ "Install it with: pip install roomkit[gemini]"
32
+ ) from exc
33
+
34
+ self._config = config
35
+ self._genai = _genai
36
+ self._types = _types
37
+ self._client = _genai.Client(api_key=config.api_key.get_secret_value())
38
+
39
+ @property
40
+ def model_name(self) -> str:
41
+ return self._config.model
42
+
43
+ @property
44
+ def supports_vision(self) -> bool:
45
+ """All Gemini models support vision."""
46
+ return True
47
+
48
+ def _format_messages(self, messages: list[AIMessage]) -> list[Any]:
49
+ """Convert AIMessage list to Gemini Content format."""
50
+ contents = []
51
+ for msg in messages:
52
+ role = "model" if msg.role == "assistant" else "user"
53
+ parts = self._format_content(msg.content)
54
+ contents.append(self._types.Content(role=role, parts=parts))
55
+ return contents
56
+
57
+ def _format_content(self, content: str | list[AITextPart | AIImagePart]) -> list[Any]:
58
+ """Convert content to Gemini Parts."""
59
+ if isinstance(content, str):
60
+ return [self._types.Part.from_text(text=content)]
61
+
62
+ parts = []
63
+ for item in content:
64
+ if isinstance(item, AITextPart):
65
+ parts.append(self._types.Part.from_text(text=item.text))
66
+ elif isinstance(item, AIImagePart):
67
+ parts.append(
68
+ self._types.Part.from_uri(
69
+ file_uri=item.url,
70
+ mime_type=item.mime_type or "image/jpeg",
71
+ )
72
+ )
73
+ return parts
74
+
75
+ def _extract_tool_calls(self, response: Any) -> list[AIToolCall]:
76
+ """Extract tool calls from Gemini response."""
77
+ tool_calls: list[AIToolCall] = []
78
+ if not response.candidates:
79
+ return tool_calls
80
+
81
+ for part in response.candidates[0].content.parts:
82
+ if hasattr(part, "function_call") and part.function_call:
83
+ fc = part.function_call
84
+ tool_calls.append(
85
+ AIToolCall(
86
+ id=fc.name, # Gemini doesn't provide separate IDs
87
+ name=fc.name,
88
+ arguments=dict(fc.args) if fc.args else {},
89
+ )
90
+ )
91
+ return tool_calls
92
+
93
+ async def generate(self, context: AIContext) -> AIResponse:
94
+ # Build generation config
95
+ gen_config = self._types.GenerateContentConfig(
96
+ temperature=context.temperature,
97
+ max_output_tokens=context.max_tokens,
98
+ )
99
+
100
+ if context.system_prompt:
101
+ gen_config.system_instruction = context.system_prompt
102
+
103
+ # Add tools if provided
104
+ if context.tools:
105
+ func_decls = [
106
+ self._types.FunctionDeclaration(
107
+ name=t.name,
108
+ description=t.description,
109
+ # Cast to Any: Gemini SDK accepts dict as Schema at runtime
110
+ parameters=cast(Any, t.parameters) if t.parameters else None,
111
+ )
112
+ for t in context.tools
113
+ ]
114
+ gen_config.tools = [self._types.Tool(function_declarations=func_decls)]
115
+
116
+ # Format messages
117
+ contents = self._format_messages(context.messages)
118
+
119
+ try:
120
+ # Generate (sync API, wrap in executor for async)
121
+ response = await asyncio.to_thread(
122
+ self._client.models.generate_content,
123
+ model=self._config.model,
124
+ contents=contents,
125
+ config=gen_config,
126
+ )
127
+ except Exception as exc:
128
+ # Check for rate limit or server errors
129
+ exc_str = str(exc).lower()
130
+ retryable = any(
131
+ term in exc_str for term in ["rate", "limit", "429", "500", "502", "503"]
132
+ )
133
+ raise ProviderError(
134
+ str(exc),
135
+ retryable=retryable,
136
+ provider="gemini",
137
+ status_code=None,
138
+ ) from exc
139
+
140
+ # Extract usage metadata
141
+ usage: dict[str, int] = {}
142
+ if response.usage_metadata:
143
+ usage = {
144
+ "prompt_tokens": response.usage_metadata.prompt_token_count or 0,
145
+ "completion_tokens": response.usage_metadata.candidates_token_count or 0,
146
+ }
147
+
148
+ return AIResponse(
149
+ content=response.text or "",
150
+ usage=usage,
151
+ tool_calls=self._extract_tool_calls(response),
152
+ metadata={"model": self._config.model},
153
+ )
@@ -0,0 +1,14 @@
1
+ """Google Gemini provider configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import BaseModel, SecretStr
6
+
7
+
8
+ class GeminiConfig(BaseModel):
9
+ """Google Gemini AI provider configuration."""
10
+
11
+ api_key: SecretStr
12
+ model: str = "gemini-2.0-flash"
13
+ max_tokens: int = 1024
14
+ temperature: float = 1.0 # Gemini default
@@ -0,0 +1,15 @@
1
+ """Generic HTTP webhook provider."""
2
+
3
+ from roomkit.providers.http.base import HTTPProvider
4
+ from roomkit.providers.http.config import HTTPProviderConfig
5
+ from roomkit.providers.http.mock import MockHTTPProvider
6
+ from roomkit.providers.http.provider import WebhookHTTPProvider
7
+ from roomkit.providers.http.webhook import parse_http_webhook
8
+
9
+ __all__ = [
10
+ "HTTPProvider",
11
+ "HTTPProviderConfig",
12
+ "MockHTTPProvider",
13
+ "WebhookHTTPProvider",
14
+ "parse_http_webhook",
15
+ ]
@@ -0,0 +1,33 @@
1
+ """Abstract base class for HTTP 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 HTTPProvider(ABC):
12
+ """Generic HTTP webhook 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 an event via HTTP webhook.
22
+
23
+ Args:
24
+ event: The room event to deliver.
25
+ to: Target webhook URL.
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,14 @@
1
+ """HTTP webhook provider configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import BaseModel, SecretStr
6
+
7
+
8
+ class HTTPProviderConfig(BaseModel):
9
+ """Configuration for the generic HTTP webhook provider."""
10
+
11
+ webhook_url: str
12
+ secret: SecretStr | None = None
13
+ timeout: float = 30.0
14
+ headers: dict[str, str] = {}
@@ -0,0 +1,21 @@
1
+ """Mock HTTP 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.http.base import HTTPProvider
11
+
12
+
13
+ class MockHTTPProvider(HTTPProvider):
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)