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,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,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,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)
|