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,203 @@
1
+ """Event and content models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+ from typing import Annotated, Any, Literal
7
+ from uuid import uuid4
8
+
9
+ from pydantic import BaseModel, Field, field_validator, model_validator
10
+
11
+ from roomkit.models.enums import (
12
+ ChannelDirection,
13
+ ChannelType,
14
+ EventStatus,
15
+ EventType,
16
+ )
17
+
18
+
19
+ class TextContent(BaseModel):
20
+ """Plain text message content."""
21
+
22
+ type: Literal["text"] = "text"
23
+ body: str
24
+ language: str | None = None
25
+
26
+
27
+ class RichContent(BaseModel):
28
+ """Rich formatted content (HTML/Markdown)."""
29
+
30
+ type: Literal["rich"] = "rich"
31
+ body: str
32
+ format: Literal["html", "markdown"] = "markdown"
33
+ plain_text: str | None = None
34
+ buttons: list[dict[str, Any]] = Field(default_factory=list)
35
+ cards: list[dict[str, Any]] = Field(default_factory=list)
36
+ quick_replies: list[str] = Field(default_factory=list)
37
+
38
+
39
+ class MediaContent(BaseModel):
40
+ """Media attachment content."""
41
+
42
+ type: Literal["media"] = "media"
43
+ url: str
44
+ mime_type: str
45
+ filename: str | None = None
46
+ size_bytes: int | None = Field(default=None, ge=0)
47
+ caption: str | None = None
48
+
49
+ @field_validator("url")
50
+ @classmethod
51
+ def _validate_url(cls, v: str) -> str:
52
+ if not v.startswith(("http://", "https://")):
53
+ raise ValueError("URL must start with http:// or https://")
54
+ return v
55
+
56
+
57
+ class LocationContent(BaseModel):
58
+ """Geographic location content."""
59
+
60
+ type: Literal["location"] = "location"
61
+ latitude: float = Field(ge=-90.0, le=90.0)
62
+ longitude: float = Field(ge=-180.0, le=180.0)
63
+ label: str | None = None
64
+ address: str | None = None
65
+
66
+
67
+ class AudioContent(BaseModel):
68
+ """Audio message content."""
69
+
70
+ type: Literal["audio"] = "audio"
71
+ url: str
72
+ mime_type: str = "audio/ogg"
73
+ duration_seconds: float | None = Field(default=None, ge=0.0)
74
+ transcript: str | None = None
75
+
76
+ @field_validator("url")
77
+ @classmethod
78
+ def _validate_url(cls, v: str) -> str:
79
+ if not v.startswith(("http://", "https://")):
80
+ raise ValueError("URL must start with http:// or https://")
81
+ return v
82
+
83
+
84
+ class VideoContent(BaseModel):
85
+ """Video message content."""
86
+
87
+ type: Literal["video"] = "video"
88
+ url: str
89
+ mime_type: str = "video/mp4"
90
+ duration_seconds: float | None = Field(default=None, ge=0.0)
91
+ thumbnail_url: str | None = None
92
+
93
+ @field_validator("url", "thumbnail_url")
94
+ @classmethod
95
+ def _validate_url(cls, v: str | None) -> str | None:
96
+ if v is not None and not v.startswith(("http://", "https://")):
97
+ raise ValueError("URL must start with http:// or https://")
98
+ return v
99
+
100
+
101
+ class CompositeContent(BaseModel):
102
+ """Multi-part content combining multiple content types."""
103
+
104
+ type: Literal["composite"] = "composite"
105
+ parts: list[EventContent]
106
+
107
+ @model_validator(mode="after")
108
+ def _validate_parts(self) -> CompositeContent:
109
+ if not self.parts:
110
+ raise ValueError("CompositeContent must have at least one part")
111
+ depth = self._nesting_depth(self)
112
+ if depth > 5:
113
+ raise ValueError(f"CompositeContent nesting depth {depth} exceeds maximum of 5")
114
+ return self
115
+
116
+ @staticmethod
117
+ def _nesting_depth(content: object, current: int = 1) -> int:
118
+ """Recursively compute nesting depth of CompositeContent."""
119
+ if not isinstance(content, CompositeContent):
120
+ return 0
121
+ max_child = 0
122
+ for part in content.parts:
123
+ child_depth = CompositeContent._nesting_depth(part, current + 1)
124
+ if child_depth > max_child:
125
+ max_child = child_depth
126
+ return 1 + max_child
127
+
128
+
129
+ class SystemContent(BaseModel):
130
+ """System-generated content."""
131
+
132
+ type: Literal["system"] = "system"
133
+ body: str
134
+ code: str | None = None
135
+ data: dict[str, Any] = Field(default_factory=dict)
136
+
137
+
138
+ class TemplateContent(BaseModel):
139
+ """Pre-approved template content (WhatsApp Business, etc.)."""
140
+
141
+ type: Literal["template"] = "template"
142
+ template_id: str
143
+ language: str = "en"
144
+ parameters: dict[str, str] = Field(default_factory=dict)
145
+ body: str | None = None
146
+
147
+
148
+ EventContent = Annotated[
149
+ TextContent
150
+ | RichContent
151
+ | MediaContent
152
+ | LocationContent
153
+ | AudioContent
154
+ | VideoContent
155
+ | CompositeContent
156
+ | SystemContent
157
+ | TemplateContent,
158
+ Field(discriminator="type"),
159
+ ]
160
+
161
+
162
+ class ChannelData(BaseModel):
163
+ """Provider-specific channel metadata."""
164
+
165
+ provider: str | None = None
166
+ external_id: str | None = None
167
+ thread_id: str | None = None
168
+ extra: dict[str, Any] = Field(default_factory=dict)
169
+
170
+
171
+ class EventSource(BaseModel):
172
+ """Origin information for an event."""
173
+
174
+ channel_id: str
175
+ channel_type: ChannelType
176
+ direction: ChannelDirection = ChannelDirection.INBOUND
177
+ participant_id: str | None = None
178
+ external_id: str | None = None
179
+ provider: str | None = None
180
+ raw_payload: dict[str, Any] = Field(default_factory=dict)
181
+ provider_message_id: str | None = None
182
+
183
+
184
+ class RoomEvent(BaseModel):
185
+ """A single event in a room conversation."""
186
+
187
+ id: str = Field(default_factory=lambda: uuid4().hex)
188
+ room_id: str
189
+ type: EventType = EventType.MESSAGE
190
+ source: EventSource
191
+ content: EventContent
192
+ status: EventStatus = EventStatus.PENDING
193
+ blocked_by: str | None = None
194
+ visibility: str = "all"
195
+ index: int = Field(default=0, ge=0)
196
+ chain_depth: int = Field(default=0, ge=0)
197
+ parent_event_id: str | None = None
198
+ correlation_id: str | None = None
199
+ idempotency_key: str | None = None
200
+ created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
201
+ metadata: dict[str, Any] = Field(default_factory=dict)
202
+ channel_data: ChannelData = Field(default_factory=ChannelData)
203
+ delivery_results: dict[str, Any] = Field(default_factory=dict)
@@ -0,0 +1,19 @@
1
+ """Framework-level event model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+
11
+ class FrameworkEvent(BaseModel):
12
+ """An event emitted by the framework for observability."""
13
+
14
+ type: str
15
+ room_id: str | None = None
16
+ channel_id: str | None = None
17
+ event_id: str | None = None
18
+ timestamp: datetime = Field(default_factory=lambda: datetime.now(UTC))
19
+ data: dict[str, Any] = Field(default_factory=dict)
roomkit/models/hook.py ADDED
@@ -0,0 +1,68 @@
1
+ """Hook-related models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Literal
6
+
7
+ from pydantic import BaseModel, Field, model_validator
8
+
9
+ from roomkit.models.event import RoomEvent
10
+ from roomkit.models.task import Observation, Task
11
+
12
+
13
+ class InjectedEvent(BaseModel):
14
+ """An event injected by a hook as a side effect."""
15
+
16
+ event: RoomEvent
17
+ target_channel_ids: list[str] | None = None
18
+
19
+
20
+ class HookResult(BaseModel):
21
+ """Result returned by a sync hook."""
22
+
23
+ action: Literal["allow", "block", "modify"]
24
+ event: RoomEvent | None = None
25
+ reason: str | None = None
26
+ injected_events: list[InjectedEvent] = Field(default_factory=list)
27
+ tasks: list[Task] = Field(default_factory=list)
28
+ observations: list[Observation] = Field(default_factory=list)
29
+ metadata: dict[str, Any] = Field(default_factory=dict)
30
+
31
+ @model_validator(mode="after")
32
+ def _validate_action_fields(self) -> HookResult:
33
+ if self.action == "modify" and self.event is None:
34
+ raise ValueError("action='modify' requires 'event' to be set")
35
+ if self.action == "block" and self.reason is None:
36
+ raise ValueError("action='block' requires 'reason' to be set")
37
+ return self
38
+
39
+ @classmethod
40
+ def allow(cls, injected: list[InjectedEvent] | None = None) -> HookResult:
41
+ """Allow the event to proceed."""
42
+ return cls(action="allow", injected_events=injected or [])
43
+
44
+ @classmethod
45
+ def block(
46
+ cls,
47
+ reason: str,
48
+ injected: list[InjectedEvent] | None = None,
49
+ tasks: list[Task] | None = None,
50
+ observations: list[Observation] | None = None,
51
+ ) -> HookResult:
52
+ """Block the event from proceeding."""
53
+ return cls(
54
+ action="block",
55
+ reason=reason,
56
+ injected_events=injected or [],
57
+ tasks=tasks or [],
58
+ observations=observations or [],
59
+ )
60
+
61
+ @classmethod
62
+ def modify(
63
+ cls,
64
+ event: RoomEvent,
65
+ injected: list[InjectedEvent] | None = None,
66
+ ) -> HookResult:
67
+ """Modify the event before it proceeds."""
68
+ return cls(action="modify", event=event, injected_events=injected or [])
@@ -0,0 +1,81 @@
1
+ """Identity and identification models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+ from roomkit.models.enums import IdentificationStatus
10
+ from roomkit.models.hook import InjectedEvent
11
+
12
+
13
+ class Identity(BaseModel):
14
+ """A resolved user identity."""
15
+
16
+ id: str
17
+ organization_id: str | None = None
18
+ display_name: str | None = None
19
+ email: str | None = None
20
+ phone: str | None = None
21
+ channel_addresses: dict[str, list[str]] = Field(default_factory=dict)
22
+ external_id: str | None = None
23
+ external_ids: dict[str, str] = Field(default_factory=dict)
24
+ metadata: dict[str, Any] = Field(default_factory=dict)
25
+
26
+
27
+ class IdentityResult(BaseModel):
28
+ """Result of identity resolution."""
29
+
30
+ status: IdentificationStatus
31
+ identity: Identity | None = None
32
+ candidates: list[Identity] = Field(default_factory=list)
33
+ address: str | None = None
34
+ channel_type: str | None = None
35
+ challenge_type: str | None = None
36
+ message: str | None = None
37
+
38
+
39
+ class IdentityHookResult(BaseModel):
40
+ """Result from an identity resolution hook."""
41
+
42
+ status: IdentificationStatus
43
+ identity: Identity | None = None
44
+ display_name: str | None = None
45
+ candidates: list[Identity] | None = None
46
+ inject: InjectedEvent | None = None
47
+ reason: str | None = None
48
+ challenge_type: str | None = None
49
+ message: str | None = None
50
+
51
+ @classmethod
52
+ def resolved(cls, identity: Identity) -> IdentityHookResult:
53
+ """Resolved - we know who this is."""
54
+ return cls(status=IdentificationStatus.IDENTIFIED, identity=identity)
55
+
56
+ @classmethod
57
+ def pending(
58
+ cls,
59
+ display_name: str | None = None,
60
+ candidates: list[Identity] | None = None,
61
+ ) -> IdentityHookResult:
62
+ """Pending - create participant with status=pending. Advisor resolves later."""
63
+ return cls(
64
+ status=IdentificationStatus.PENDING,
65
+ display_name=display_name,
66
+ candidates=candidates,
67
+ )
68
+
69
+ @classmethod
70
+ def challenge(cls, inject: InjectedEvent, message: str | None = None) -> IdentityHookResult:
71
+ """Challenge - hold the message, ask the sender to self-identify."""
72
+ return cls(
73
+ status=IdentificationStatus.CHALLENGE_SENT,
74
+ inject=inject,
75
+ message=message,
76
+ )
77
+
78
+ @classmethod
79
+ def reject(cls, reason: str = "Unknown sender") -> IdentityHookResult:
80
+ """Reject - do not create room or participant."""
81
+ return cls(status=IdentificationStatus.REJECTED, reason=reason)
@@ -0,0 +1,34 @@
1
+ """Participant model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+ from roomkit.models.enums import (
11
+ IdentificationStatus,
12
+ ParticipantRole,
13
+ ParticipantStatus,
14
+ )
15
+
16
+
17
+ class Participant(BaseModel):
18
+ """A participant in a room conversation."""
19
+
20
+ id: str
21
+ room_id: str
22
+ channel_id: str
23
+ display_name: str | None = None
24
+ role: ParticipantRole = ParticipantRole.MEMBER
25
+ status: ParticipantStatus = ParticipantStatus.ACTIVE
26
+ identification: IdentificationStatus = IdentificationStatus.PENDING
27
+ identity_id: str | None = None
28
+ candidates: list[str] | None = None
29
+ connected_via: list[str] = Field(default_factory=list)
30
+ external_id: str | None = None
31
+ resolved_at: datetime | None = None
32
+ resolved_by: str | None = None
33
+ joined_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
34
+ metadata: dict[str, Any] = Field(default_factory=dict)
roomkit/models/room.py ADDED
@@ -0,0 +1,33 @@
1
+ """Room model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+ from roomkit.models.enums import RoomStatus
11
+
12
+
13
+ class RoomTimers(BaseModel):
14
+ """Timer configuration for a room."""
15
+
16
+ inactive_after_seconds: int | None = Field(default=None, ge=0)
17
+ closed_after_seconds: int | None = Field(default=None, ge=0)
18
+ last_activity_at: datetime | None = None
19
+
20
+
21
+ class Room(BaseModel):
22
+ """A conversation room."""
23
+
24
+ id: str
25
+ organization_id: str | None = None
26
+ status: RoomStatus = RoomStatus.ACTIVE
27
+ created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
28
+ updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
29
+ closed_at: datetime | None = None
30
+ timers: RoomTimers = Field(default_factory=RoomTimers)
31
+ metadata: dict[str, Any] = Field(default_factory=dict)
32
+ event_count: int = Field(default=0, ge=0)
33
+ latest_index: int = Field(default=0, ge=0)
roomkit/models/task.py ADDED
@@ -0,0 +1,36 @@
1
+ """Task and observation models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+ from roomkit.models.enums import TaskStatus
11
+
12
+
13
+ class Task(BaseModel):
14
+ """A task assigned within a room."""
15
+
16
+ id: str
17
+ room_id: str
18
+ title: str
19
+ description: str | None = None
20
+ assigned_to: str | None = None
21
+ status: TaskStatus = TaskStatus.PENDING
22
+ created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
23
+ metadata: dict[str, Any] = Field(default_factory=dict)
24
+
25
+
26
+ class Observation(BaseModel):
27
+ """An observation produced by an intelligence channel."""
28
+
29
+ id: str
30
+ room_id: str
31
+ channel_id: str
32
+ content: str
33
+ category: str | None = None
34
+ confidence: float = Field(default=1.0, ge=0.0, le=1.0)
35
+ created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
36
+ metadata: dict[str, Any] = Field(default_factory=dict)
File without changes
File without changes
@@ -0,0 +1,140 @@
1
+ """Abstract base class for AI providers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import Any, Literal
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+ from roomkit.models.channel import ChannelCapabilities
11
+ from roomkit.models.context import RoomContext
12
+ from roomkit.models.enums import ChannelMediaType
13
+ from roomkit.models.task import Observation, Task
14
+
15
+
16
+ class AITextPart(BaseModel):
17
+ """Text part of a multimodal message."""
18
+
19
+ type: Literal["text"] = "text"
20
+ text: str
21
+
22
+
23
+ class AIImagePart(BaseModel):
24
+ """Image part of a multimodal message."""
25
+
26
+ type: Literal["image"] = "image"
27
+ url: str
28
+ mime_type: str | None = None
29
+
30
+
31
+ class AITool(BaseModel):
32
+ """Tool definition for function calling."""
33
+
34
+ name: str
35
+ description: str
36
+ parameters: dict[str, Any] = Field(default_factory=dict)
37
+
38
+
39
+ class AIToolCall(BaseModel):
40
+ """A tool call from the AI response."""
41
+
42
+ id: str
43
+ name: str
44
+ arguments: dict[str, Any] = Field(default_factory=dict)
45
+
46
+
47
+ class ProviderError(Exception):
48
+ """Error from an AI provider SDK call.
49
+
50
+ Attributes:
51
+ retryable: Whether the caller should retry the request.
52
+ provider: Name of the provider that raised the error.
53
+ status_code: HTTP status code from the provider, if available.
54
+ """
55
+
56
+ def __init__(
57
+ self,
58
+ message: str,
59
+ *,
60
+ retryable: bool = False,
61
+ provider: str = "",
62
+ status_code: int | None = None,
63
+ ) -> None:
64
+ super().__init__(message)
65
+ self.retryable = retryable
66
+ self.provider = provider
67
+ self.status_code = status_code
68
+
69
+
70
+ class AIMessage(BaseModel):
71
+ """A message in the AI conversation context."""
72
+
73
+ role: str # "system", "user", "assistant"
74
+ content: str | list[AITextPart | AIImagePart]
75
+ metadata: dict[str, Any] = Field(default_factory=dict)
76
+
77
+
78
+ class AIContext(BaseModel):
79
+ """Context passed to AI provider for generation."""
80
+
81
+ model_config = {"arbitrary_types_allowed": True}
82
+
83
+ messages: list[AIMessage] = Field(default_factory=list)
84
+ system_prompt: str | None = None
85
+ temperature: float = 0.7
86
+ max_tokens: int = 1024
87
+ tools: list[AITool] = Field(default_factory=list)
88
+ room: RoomContext | None = None
89
+ target_capabilities: ChannelCapabilities | None = None
90
+ target_media_types: list[ChannelMediaType] = Field(default_factory=list)
91
+ metadata: dict[str, Any] = Field(default_factory=dict)
92
+
93
+
94
+ class AIResponse(BaseModel):
95
+ """Response from an AI provider."""
96
+
97
+ content: str
98
+ finish_reason: str | None = None
99
+ usage: dict[str, int] = Field(default_factory=dict)
100
+ metadata: dict[str, Any] = Field(default_factory=dict)
101
+ tasks: list[Task] = Field(default_factory=list)
102
+ observations: list[Observation] = Field(default_factory=list)
103
+ tool_calls: list[AIToolCall] = Field(default_factory=list)
104
+
105
+
106
+ class AIProvider(ABC):
107
+ """AI model provider for generating responses."""
108
+
109
+ @property
110
+ def name(self) -> str:
111
+ """Provider name (e.g. 'anthropic', 'openai')."""
112
+ return self.__class__.__name__
113
+
114
+ @property
115
+ def supports_vision(self) -> bool:
116
+ """Whether this provider can process images."""
117
+ return False
118
+
119
+ @property
120
+ @abstractmethod
121
+ def model_name(self) -> str:
122
+ """Model identifier (e.g. 'claude-sonnet-4-20250514', 'gpt-4o')."""
123
+ ...
124
+
125
+ @abstractmethod
126
+ async def generate(self, context: AIContext) -> AIResponse:
127
+ """Generate an AI response from the given context.
128
+
129
+ Args:
130
+ context: Conversation context including messages, system prompt,
131
+ temperature, and target channel capabilities.
132
+
133
+ Returns:
134
+ The AI response with content, usage stats, and optional
135
+ tasks/observations.
136
+ """
137
+ ...
138
+
139
+ async def close(self) -> None: # noqa: B027
140
+ """Release resources. Override in subclasses that hold connections."""
@@ -0,0 +1,33 @@
1
+ """Mock AI provider for testing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from roomkit.providers.ai.base import AIContext, AIProvider, AIResponse
6
+
7
+
8
+ class MockAIProvider(AIProvider):
9
+ """Round-robin response provider for tests."""
10
+
11
+ def __init__(self, responses: list[str] | None = None, *, vision: bool = False) -> None:
12
+ self.responses = responses or ["Hello from AI"]
13
+ self.calls: list[AIContext] = []
14
+ self._index = 0
15
+ self._vision = vision
16
+
17
+ @property
18
+ def model_name(self) -> str:
19
+ return "mock"
20
+
21
+ @property
22
+ def supports_vision(self) -> bool:
23
+ return self._vision
24
+
25
+ async def generate(self, context: AIContext) -> AIResponse:
26
+ self.calls.append(context)
27
+ content = self.responses[self._index % len(self.responses)]
28
+ self._index += 1
29
+ return AIResponse(
30
+ content=content,
31
+ finish_reason="stop",
32
+ usage={"prompt_tokens": 10, "completion_tokens": 5},
33
+ )
@@ -0,0 +1,6 @@
1
+ """Anthropic provider."""
2
+
3
+ from roomkit.providers.anthropic.ai import AnthropicAIProvider
4
+ from roomkit.providers.anthropic.config import AnthropicConfig
5
+
6
+ __all__ = ["AnthropicAIProvider", "AnthropicConfig"]