spaik-sdk 0.6.2__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 (161) hide show
  1. spaik_sdk/__init__.py +21 -0
  2. spaik_sdk/agent/__init__.py +0 -0
  3. spaik_sdk/agent/base_agent.py +249 -0
  4. spaik_sdk/attachments/__init__.py +22 -0
  5. spaik_sdk/attachments/builder.py +61 -0
  6. spaik_sdk/attachments/file_storage_provider.py +27 -0
  7. spaik_sdk/attachments/mime_types.py +118 -0
  8. spaik_sdk/attachments/models.py +63 -0
  9. spaik_sdk/attachments/provider_support.py +53 -0
  10. spaik_sdk/attachments/storage/__init__.py +0 -0
  11. spaik_sdk/attachments/storage/base_file_storage.py +32 -0
  12. spaik_sdk/attachments/storage/impl/__init__.py +0 -0
  13. spaik_sdk/attachments/storage/impl/local_file_storage.py +101 -0
  14. spaik_sdk/audio/__init__.py +12 -0
  15. spaik_sdk/audio/options.py +53 -0
  16. spaik_sdk/audio/providers/__init__.py +1 -0
  17. spaik_sdk/audio/providers/google_tts.py +77 -0
  18. spaik_sdk/audio/providers/openai_stt.py +71 -0
  19. spaik_sdk/audio/providers/openai_tts.py +111 -0
  20. spaik_sdk/audio/stt.py +61 -0
  21. spaik_sdk/audio/tts.py +124 -0
  22. spaik_sdk/config/credentials_provider.py +10 -0
  23. spaik_sdk/config/env.py +59 -0
  24. spaik_sdk/config/env_credentials_provider.py +7 -0
  25. spaik_sdk/config/get_credentials_provider.py +14 -0
  26. spaik_sdk/image_gen/__init__.py +9 -0
  27. spaik_sdk/image_gen/image_generator.py +83 -0
  28. spaik_sdk/image_gen/options.py +24 -0
  29. spaik_sdk/image_gen/providers/__init__.py +0 -0
  30. spaik_sdk/image_gen/providers/google.py +75 -0
  31. spaik_sdk/image_gen/providers/openai.py +60 -0
  32. spaik_sdk/llm/__init__.py +0 -0
  33. spaik_sdk/llm/cancellation_handle.py +10 -0
  34. spaik_sdk/llm/consumption/__init__.py +0 -0
  35. spaik_sdk/llm/consumption/consumption_estimate.py +26 -0
  36. spaik_sdk/llm/consumption/consumption_estimate_builder.py +113 -0
  37. spaik_sdk/llm/consumption/consumption_extractor.py +59 -0
  38. spaik_sdk/llm/consumption/token_usage.py +31 -0
  39. spaik_sdk/llm/converters.py +146 -0
  40. spaik_sdk/llm/cost/__init__.py +1 -0
  41. spaik_sdk/llm/cost/builtin_cost_provider.py +83 -0
  42. spaik_sdk/llm/cost/cost_estimate.py +8 -0
  43. spaik_sdk/llm/cost/cost_provider.py +28 -0
  44. spaik_sdk/llm/extract_error_message.py +37 -0
  45. spaik_sdk/llm/langchain_loop_manager.py +270 -0
  46. spaik_sdk/llm/langchain_service.py +196 -0
  47. spaik_sdk/llm/message_handler.py +188 -0
  48. spaik_sdk/llm/streaming/__init__.py +1 -0
  49. spaik_sdk/llm/streaming/block_manager.py +152 -0
  50. spaik_sdk/llm/streaming/models.py +42 -0
  51. spaik_sdk/llm/streaming/streaming_content_handler.py +157 -0
  52. spaik_sdk/llm/streaming/streaming_event_handler.py +215 -0
  53. spaik_sdk/llm/streaming/streaming_state_manager.py +58 -0
  54. spaik_sdk/models/__init__.py +0 -0
  55. spaik_sdk/models/factories/__init__.py +0 -0
  56. spaik_sdk/models/factories/anthropic_factory.py +33 -0
  57. spaik_sdk/models/factories/base_model_factory.py +71 -0
  58. spaik_sdk/models/factories/google_factory.py +30 -0
  59. spaik_sdk/models/factories/ollama_factory.py +41 -0
  60. spaik_sdk/models/factories/openai_factory.py +50 -0
  61. spaik_sdk/models/llm_config.py +46 -0
  62. spaik_sdk/models/llm_families.py +7 -0
  63. spaik_sdk/models/llm_model.py +17 -0
  64. spaik_sdk/models/llm_wrapper.py +25 -0
  65. spaik_sdk/models/model_registry.py +156 -0
  66. spaik_sdk/models/providers/__init__.py +0 -0
  67. spaik_sdk/models/providers/anthropic_provider.py +29 -0
  68. spaik_sdk/models/providers/azure_provider.py +31 -0
  69. spaik_sdk/models/providers/base_provider.py +62 -0
  70. spaik_sdk/models/providers/google_provider.py +26 -0
  71. spaik_sdk/models/providers/ollama_provider.py +26 -0
  72. spaik_sdk/models/providers/openai_provider.py +26 -0
  73. spaik_sdk/models/providers/provider_type.py +90 -0
  74. spaik_sdk/orchestration/__init__.py +24 -0
  75. spaik_sdk/orchestration/base_orchestrator.py +238 -0
  76. spaik_sdk/orchestration/checkpoint.py +80 -0
  77. spaik_sdk/orchestration/models.py +103 -0
  78. spaik_sdk/prompt/__init__.py +0 -0
  79. spaik_sdk/prompt/get_prompt_loader.py +13 -0
  80. spaik_sdk/prompt/local_prompt_loader.py +21 -0
  81. spaik_sdk/prompt/prompt_loader.py +48 -0
  82. spaik_sdk/prompt/prompt_loader_mode.py +14 -0
  83. spaik_sdk/py.typed +1 -0
  84. spaik_sdk/recording/__init__.py +1 -0
  85. spaik_sdk/recording/base_playback.py +90 -0
  86. spaik_sdk/recording/base_recorder.py +50 -0
  87. spaik_sdk/recording/conditional_recorder.py +38 -0
  88. spaik_sdk/recording/impl/__init__.py +1 -0
  89. spaik_sdk/recording/impl/local_playback.py +76 -0
  90. spaik_sdk/recording/impl/local_recorder.py +85 -0
  91. spaik_sdk/recording/langchain_serializer.py +88 -0
  92. spaik_sdk/server/__init__.py +1 -0
  93. spaik_sdk/server/api/routers/__init__.py +0 -0
  94. spaik_sdk/server/api/routers/api_builder.py +149 -0
  95. spaik_sdk/server/api/routers/audio_router_factory.py +201 -0
  96. spaik_sdk/server/api/routers/file_router_factory.py +111 -0
  97. spaik_sdk/server/api/routers/thread_router_factory.py +284 -0
  98. spaik_sdk/server/api/streaming/__init__.py +0 -0
  99. spaik_sdk/server/api/streaming/format_sse_event.py +41 -0
  100. spaik_sdk/server/api/streaming/negotiate_streaming_response.py +8 -0
  101. spaik_sdk/server/api/streaming/streaming_negotiator.py +10 -0
  102. spaik_sdk/server/authorization/__init__.py +0 -0
  103. spaik_sdk/server/authorization/base_authorizer.py +64 -0
  104. spaik_sdk/server/authorization/base_user.py +13 -0
  105. spaik_sdk/server/authorization/dummy_authorizer.py +17 -0
  106. spaik_sdk/server/job_processor/__init__.py +0 -0
  107. spaik_sdk/server/job_processor/base_job_processor.py +8 -0
  108. spaik_sdk/server/job_processor/thread_job_processor.py +32 -0
  109. spaik_sdk/server/pubsub/__init__.py +1 -0
  110. spaik_sdk/server/pubsub/cancellation_publisher.py +7 -0
  111. spaik_sdk/server/pubsub/cancellation_subscriber.py +38 -0
  112. spaik_sdk/server/pubsub/event_publisher.py +13 -0
  113. spaik_sdk/server/pubsub/impl/__init__.py +1 -0
  114. spaik_sdk/server/pubsub/impl/local_cancellation_pubsub.py +48 -0
  115. spaik_sdk/server/pubsub/impl/signalr_publisher.py +36 -0
  116. spaik_sdk/server/queue/__init__.py +1 -0
  117. spaik_sdk/server/queue/agent_job_queue.py +27 -0
  118. spaik_sdk/server/queue/impl/__init__.py +1 -0
  119. spaik_sdk/server/queue/impl/azure_queue.py +24 -0
  120. spaik_sdk/server/response/__init__.py +0 -0
  121. spaik_sdk/server/response/agent_response_generator.py +39 -0
  122. spaik_sdk/server/response/response_generator.py +13 -0
  123. spaik_sdk/server/response/simple_agent_response_generator.py +14 -0
  124. spaik_sdk/server/services/__init__.py +0 -0
  125. spaik_sdk/server/services/thread_converters.py +113 -0
  126. spaik_sdk/server/services/thread_models.py +90 -0
  127. spaik_sdk/server/services/thread_service.py +91 -0
  128. spaik_sdk/server/storage/__init__.py +1 -0
  129. spaik_sdk/server/storage/base_thread_repository.py +51 -0
  130. spaik_sdk/server/storage/impl/__init__.py +0 -0
  131. spaik_sdk/server/storage/impl/in_memory_thread_repository.py +100 -0
  132. spaik_sdk/server/storage/impl/local_file_thread_repository.py +217 -0
  133. spaik_sdk/server/storage/thread_filter.py +166 -0
  134. spaik_sdk/server/storage/thread_metadata.py +53 -0
  135. spaik_sdk/thread/__init__.py +0 -0
  136. spaik_sdk/thread/adapters/__init__.py +0 -0
  137. spaik_sdk/thread/adapters/cli/__init__.py +0 -0
  138. spaik_sdk/thread/adapters/cli/block_display.py +92 -0
  139. spaik_sdk/thread/adapters/cli/display_manager.py +84 -0
  140. spaik_sdk/thread/adapters/cli/live_cli.py +235 -0
  141. spaik_sdk/thread/adapters/event_adapter.py +28 -0
  142. spaik_sdk/thread/adapters/streaming_block_adapter.py +57 -0
  143. spaik_sdk/thread/adapters/sync_adapter.py +76 -0
  144. spaik_sdk/thread/models.py +224 -0
  145. spaik_sdk/thread/thread_container.py +468 -0
  146. spaik_sdk/tools/__init__.py +0 -0
  147. spaik_sdk/tools/impl/__init__.py +0 -0
  148. spaik_sdk/tools/impl/mcp_tool_provider.py +93 -0
  149. spaik_sdk/tools/impl/search_tool_provider.py +18 -0
  150. spaik_sdk/tools/tool_provider.py +131 -0
  151. spaik_sdk/tracing/__init__.py +13 -0
  152. spaik_sdk/tracing/agent_trace.py +72 -0
  153. spaik_sdk/tracing/get_trace_sink.py +15 -0
  154. spaik_sdk/tracing/local_trace_sink.py +23 -0
  155. spaik_sdk/tracing/trace_sink.py +19 -0
  156. spaik_sdk/tracing/trace_sink_mode.py +14 -0
  157. spaik_sdk/utils/__init__.py +0 -0
  158. spaik_sdk/utils/init_logger.py +24 -0
  159. spaik_sdk-0.6.2.dist-info/METADATA +379 -0
  160. spaik_sdk-0.6.2.dist-info/RECORD +161 -0
  161. spaik_sdk-0.6.2.dist-info/WHEEL +4 -0
@@ -0,0 +1,235 @@
1
+ import asyncio
2
+ import sys
3
+ from typing import List
4
+
5
+ from rich.console import Console
6
+ from rich.prompt import Prompt
7
+
8
+ from spaik_sdk.thread.adapters.cli.block_display import BlockDisplayType
9
+ from spaik_sdk.thread.adapters.cli.display_manager import DisplayManager
10
+ from spaik_sdk.thread.models import (
11
+ BlockAddedEvent,
12
+ MessageBlockType,
13
+ StreamingEndedEvent,
14
+ StreamingUpdatedEvent,
15
+ ThreadEvent,
16
+ ToolResponseReceivedEvent,
17
+ )
18
+ from spaik_sdk.thread.thread_container import ThreadContainer
19
+
20
+ SHOULD_INITIALIZE_DISPLAY = False
21
+
22
+
23
+ class LiveCLI:
24
+ """Simple event-driven CLI that listens to ThreadContainer events"""
25
+
26
+ def __init__(self, container: ThreadContainer):
27
+ self.container = container
28
+ self.display_manager = DisplayManager()
29
+ self._running = False
30
+ self.console = Console()
31
+ self.events: List[ThreadEvent] = []
32
+
33
+ async def run(self, response_stream) -> "LiveCLI":
34
+ """Run the CLI with an async generator stream - handles everything internally"""
35
+ self.start()
36
+
37
+ try:
38
+ # Consume the response stream
39
+ async for token_data in response_stream:
40
+ # Stream processing happens automatically via events
41
+ pass
42
+
43
+ # Wait for streaming to complete
44
+ await self._wait_for_completion()
45
+
46
+ finally:
47
+ self.stop()
48
+
49
+ return self
50
+
51
+ async def run_interactive(self, agent) -> "LiveCLI":
52
+ """Run the CLI in interactive mode with a BaseAgent"""
53
+ from spaik_sdk.agent.base_agent import BaseAgent
54
+
55
+ if not isinstance(agent, BaseAgent):
56
+ raise ValueError("Agent must be an instance of BaseAgent")
57
+
58
+ # Use simple print statements for cleaner display
59
+ print("\n🤖 Interactive Agent Mode")
60
+ print("Type 'quit', 'exit', or 'q' to stop")
61
+ print("-" * 40)
62
+
63
+ while True:
64
+ try:
65
+ # Ensure clean state before input
66
+ sys.stdout.flush()
67
+ sys.stderr.flush()
68
+
69
+ # Get user input using Rich's prompt (blocking but clean)
70
+ user_input = await self._get_input_safe()
71
+
72
+ # Check for exit commands
73
+ if user_input.lower().strip() in ["quit", "exit", "q"]:
74
+ print("\n👋 Goodbye!")
75
+ break
76
+
77
+ if not user_input.strip():
78
+ continue
79
+
80
+ # Start live display for response processing
81
+ if self._running:
82
+ self.stop()
83
+ self.start()
84
+
85
+ try:
86
+ # Get response stream from agent
87
+ response_stream = agent.get_response_stream(user_input)
88
+
89
+ # Consume the response stream
90
+ async for token_data in response_stream:
91
+ # Stream processing happens automatically via events
92
+ pass
93
+
94
+ # Wait for streaming to complete
95
+ await self._wait_for_completion()
96
+
97
+ finally:
98
+ # Always stop live display after response with proper cleanup
99
+ await self._safe_stop()
100
+
101
+ except KeyboardInterrupt:
102
+ print("\n\n👋 Goodbye!")
103
+ break
104
+ except Exception as e:
105
+ print(f"\n❌ Error: {e}")
106
+ print("Try again...")
107
+
108
+ return self
109
+
110
+ async def _get_input_safe(self) -> str:
111
+ """Get user input safely without blocking issues"""
112
+ loop = asyncio.get_event_loop()
113
+ try:
114
+ # Use Rich's prompt in executor to avoid blocking
115
+ return await loop.run_in_executor(None, Prompt.ask, "\n💬 You")
116
+ except (EOFError, KeyboardInterrupt):
117
+ return "quit"
118
+
119
+ async def _safe_stop(self):
120
+ """Safely stop the display with proper error handling"""
121
+ try:
122
+ # Small delay to let display finish
123
+ await asyncio.sleep(0.2)
124
+ # Flush before stopping
125
+ sys.stdout.flush()
126
+ sys.stderr.flush()
127
+ self.stop()
128
+ except (BlockingIOError, OSError, Exception):
129
+ # If stopping fails, force reset the display state
130
+ self._running = False
131
+ if hasattr(self.display_manager, "live") and self.display_manager.live:
132
+ try:
133
+ self.display_manager.live = None
134
+ except Exception:
135
+ pass
136
+
137
+ async def _wait_for_completion(self):
138
+ """Wait for streaming to complete"""
139
+ # Wait for streaming to actually start
140
+ await asyncio.sleep(1.0)
141
+
142
+ # Wait for completion
143
+ while self.container.is_streaming_active():
144
+ await asyncio.sleep(0.1)
145
+
146
+ # Give it a moment to finalize
147
+ await asyncio.sleep(1.0)
148
+
149
+ def start(self):
150
+ """Start live display"""
151
+ if self._running:
152
+ return
153
+
154
+ self._running = True
155
+
156
+ self.display_manager.start()
157
+
158
+ # Subscribe to events BEFORE display manager is ready
159
+ self.container.subscribe(self._handle_event)
160
+ # Initialize display with existing blocks
161
+ if SHOULD_INITIALIZE_DISPLAY:
162
+ self._initialize_display()
163
+
164
+ def stop(self):
165
+ """Stop live display"""
166
+ if not self._running:
167
+ return
168
+
169
+ # Unsubscribe from events FIRST
170
+ self.container.unsubscribe(self._handle_event)
171
+
172
+ self._running = False
173
+ self.display_manager.stop()
174
+
175
+ def _initialize_display(self):
176
+ """Initialize display with existing blocks from ThreadContainer state"""
177
+ for message in self.container.messages:
178
+ for block in message.blocks:
179
+ display_type = self._get_display_type(block)
180
+ content = self.container.get_block_content(block)
181
+ self.display_manager.add_block(
182
+ block.id, display_type, content=content, streaming=block.streaming, tool_name=block.tool_name
183
+ )
184
+
185
+ def _handle_event(self, event: ThreadEvent):
186
+ """Handle all ThreadContainer events with targeted updates"""
187
+ self.events.append(event)
188
+
189
+ if isinstance(event, StreamingUpdatedEvent):
190
+ self.display_manager.update_block_content(event.block_id, event.total_content, streaming=True)
191
+
192
+ elif isinstance(event, ToolResponseReceivedEvent):
193
+ if event.block_id:
194
+ self.display_manager.update_tool_result(event.block_id, event.response if not event.error else "", error=event.error)
195
+
196
+ elif isinstance(event, BlockAddedEvent):
197
+ # Add the new block to display
198
+ display_type = self._get_display_type(event.block)
199
+ content = self.container.get_block_content(event.block)
200
+ self.display_manager.add_block(
201
+ event.block_id, display_type, content=content, streaming=event.block.streaming, tool_name=event.block.tool_name
202
+ )
203
+
204
+ elif isinstance(event, StreamingEndedEvent):
205
+ for block_id in event.completed_blocks:
206
+ self.display_manager.update_block_content(block_id, streaming=False)
207
+
208
+ def _find_block_by_id(self, block_id: str):
209
+ """Find a block by its ID"""
210
+ for message in self.container.messages:
211
+ for block in message.blocks:
212
+ if block.id == block_id:
213
+ return block
214
+ return None
215
+
216
+ def _find_tool_block_by_call_id(self, tool_call_id: str):
217
+ """Find a tool block by its call ID"""
218
+ for message in self.container.messages:
219
+ for block in message.blocks:
220
+ if block.type == MessageBlockType.TOOL_USE and block.tool_call_id == tool_call_id:
221
+ return block
222
+ return None
223
+
224
+ def _get_display_type(self, block) -> BlockDisplayType:
225
+ """Convert MessageBlockType to BlockDisplayType"""
226
+ if block.type == MessageBlockType.REASONING:
227
+ return BlockDisplayType.REASONING
228
+ elif block.type == MessageBlockType.PLAIN:
229
+ return BlockDisplayType.RESPONSE
230
+ elif block.type == MessageBlockType.TOOL_USE:
231
+ return BlockDisplayType.TOOL_CALL
232
+ elif block.type == MessageBlockType.ERROR:
233
+ return BlockDisplayType.ERROR
234
+ else:
235
+ return BlockDisplayType.RESPONSE # fallback
@@ -0,0 +1,28 @@
1
+ """
2
+ Simple synchronous adapter for ThreadContainer
3
+ """
4
+
5
+ from typing import List
6
+
7
+ from spaik_sdk.thread.models import ThreadEvent
8
+ from spaik_sdk.thread.thread_container import ThreadContainer
9
+
10
+
11
+ class EventAdapter:
12
+ def __init__(self, container: ThreadContainer):
13
+ self.events: List[ThreadEvent] = []
14
+ self.container = container
15
+
16
+ self.container.subscribe(self._handle_event)
17
+
18
+ def _handle_event(self, event: ThreadEvent):
19
+ self.events.append(event)
20
+
21
+ def flush(self) -> List[ThreadEvent]:
22
+ ret = self.events
23
+ self.events = []
24
+ return ret
25
+
26
+ def cleanup(self):
27
+ """Unsubscribe from events"""
28
+ self.container.unsubscribe(self._handle_event)
@@ -0,0 +1,57 @@
1
+ """
2
+ Streaming block adapter that yields blocks as they appear
3
+ """
4
+
5
+ import asyncio
6
+ from typing import AsyncGenerator
7
+
8
+ from spaik_sdk.thread.models import BlockAddedEvent, MessageBlock, ThreadEvent
9
+ from spaik_sdk.thread.thread_container import ThreadContainer
10
+
11
+
12
+ class StreamingBlockAdapter:
13
+ """Adapter that streams blocks as they are created until streaming ends"""
14
+
15
+ def __init__(self, container: ThreadContainer):
16
+ self.container = container
17
+ self._block_queue: asyncio.Queue[MessageBlock] = asyncio.Queue()
18
+ self._streaming_ended = False
19
+
20
+ # Subscribe to events
21
+ self.container.subscribe(self._handle_event)
22
+
23
+ def _handle_event(self, event: ThreadEvent):
24
+ """Handle ThreadContainer events"""
25
+ if isinstance(event, BlockAddedEvent):
26
+ # Queue the new block
27
+ asyncio.create_task(self._queue_block(event.block))
28
+
29
+ async def _queue_block(self, block: MessageBlock):
30
+ """Add block to queue"""
31
+ await self._block_queue.put(block)
32
+
33
+ async def stream_blocks(self) -> AsyncGenerator[MessageBlock, None]:
34
+ """
35
+ Async generator that yields blocks as they are created.
36
+ Ends when streaming is no longer active.
37
+ """
38
+ try:
39
+ while True:
40
+ # Check if streaming is still active
41
+ if not self.container.is_streaming_active() and self._block_queue.empty():
42
+ break
43
+
44
+ try:
45
+ # Wait for next block with timeout to check streaming status periodically
46
+ block = await asyncio.wait_for(self._block_queue.get(), timeout=0.1)
47
+ yield block
48
+ except asyncio.TimeoutError:
49
+ # Continue loop to check streaming status
50
+ continue
51
+
52
+ finally:
53
+ self.cleanup()
54
+
55
+ def cleanup(self):
56
+ """Unsubscribe from events"""
57
+ self.container.unsubscribe(self._handle_event)
@@ -0,0 +1,76 @@
1
+ """
2
+ Simple synchronous adapter for ThreadContainer
3
+ """
4
+
5
+ import asyncio
6
+ import time
7
+ from typing import Optional
8
+
9
+ from spaik_sdk.llm.cancellation_handle import CancellationHandle
10
+ from spaik_sdk.thread.models import StreamingEndedEvent, ThreadEvent, ThreadMessage
11
+ from spaik_sdk.thread.thread_container import ThreadContainer
12
+
13
+
14
+ class SyncAdapter:
15
+ """Simple adapter that synchronously waits for streaming to end and returns final message"""
16
+
17
+ def __init__(self, container: ThreadContainer, cancellation_handle: Optional[CancellationHandle] = None):
18
+ self.container = container
19
+ self._streaming_ended = False
20
+ self.cancellation_handle = cancellation_handle
21
+
22
+ # Subscribe to events
23
+ self.container.subscribe(self._handle_event)
24
+
25
+ def run(self, response_stream, timeout: float = 30.0) -> "SyncAdapter":
26
+ """Run the adapter with an async generator stream - handles everything internally"""
27
+ asyncio.run(self.run_async(response_stream, timeout))
28
+ return self
29
+
30
+ async def run_async(self, response_stream, timeout: float = 30.0) -> Optional[ThreadMessage]:
31
+ """Async version of run that consumes the stream and waits for completion"""
32
+ try:
33
+ # Consume the response stream
34
+ async for token_data in response_stream:
35
+ # Stream processing happens automatically via events
36
+ pass
37
+
38
+ # Wait for streaming to complete
39
+ return await self.wait_for_completion_async(timeout)
40
+
41
+ finally:
42
+ pass # Could add cleanup here if needed
43
+
44
+ def _handle_event(self, event: ThreadEvent):
45
+ """Handle ThreadContainer events"""
46
+ if isinstance(event, StreamingEndedEvent):
47
+ self._streaming_ended = True
48
+
49
+ def wait_for_completion(self, timeout: float = 30.0) -> Optional[ThreadMessage]:
50
+ """Wait for streaming to complete and return the latest AI message"""
51
+ return asyncio.run(self.wait_for_completion_async(timeout))
52
+
53
+ async def wait_for_completion_async(self, timeout: float = 30.0) -> Optional[ThreadMessage]:
54
+ """Async version of wait_for_completion"""
55
+ start_time = time.time()
56
+
57
+ # Wait for streaming to end
58
+ while time.time() - start_time < timeout:
59
+ if self._streaming_ended or not self.container.is_streaming_active():
60
+ # Give it a moment to finalize
61
+ await asyncio.sleep(0.1)
62
+ return self.container.get_latest_ai_message()
63
+
64
+ # Timeout - return what we have
65
+ return self.container.get_latest_ai_message()
66
+
67
+ def get_final_response(self) -> str:
68
+ """Get the final text response"""
69
+ message = self.wait_for_completion()
70
+ if message:
71
+ return self.container.get_final_text_content()
72
+ return ""
73
+
74
+ def cleanup(self):
75
+ """Unsubscribe from events"""
76
+ self.container.unsubscribe(self._handle_event)
@@ -0,0 +1,224 @@
1
+ import json
2
+ import time
3
+ from abc import ABC
4
+ from dataclasses import dataclass
5
+ from enum import Enum
6
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional
7
+
8
+ if TYPE_CHECKING:
9
+ from spaik_sdk.attachments.models import Attachment
10
+ from spaik_sdk.llm.consumption.token_usage import TokenUsage
11
+
12
+
13
+ class MessageBlockType(Enum):
14
+ PLAIN = "plain"
15
+ REASONING = "reasoning"
16
+ TOOL_USE = "tool_use"
17
+ ERROR = "error"
18
+
19
+
20
+ @dataclass
21
+ class MessageBlock:
22
+ id: str
23
+ streaming: bool
24
+ type: MessageBlockType
25
+ content: Optional[str] = None
26
+ tool_call_id: Optional[str] = None
27
+ tool_call_args: Optional[Dict[str, Any]] = None
28
+ tool_name: Optional[str] = None
29
+ tool_call_response: Optional[str] = None
30
+ tool_call_error: Optional[str] = None
31
+
32
+ def to_dict(self) -> Dict[str, Any]:
33
+ return {
34
+ "id": self.id,
35
+ "streaming": self.streaming,
36
+ "content": self.content,
37
+ "tool_call_id": self.tool_call_id,
38
+ "tool_call_args": self.tool_call_args,
39
+ "tool_name": self.tool_name,
40
+ "type": self.type.value,
41
+ }
42
+
43
+
44
+ @dataclass
45
+ class ThreadMessage:
46
+ id: str
47
+ ai: bool
48
+ author_id: str
49
+ author_name: str
50
+ timestamp: int # UTC millis
51
+ blocks: List[MessageBlock]
52
+ consumption_metadata: Optional["TokenUsage"] = None
53
+ attachments: Optional[List["Attachment"]] = None
54
+
55
+ def get_text_content(self) -> str:
56
+ return "\n".join([(block.content or "") for block in self.blocks if block.type == MessageBlockType.PLAIN])
57
+
58
+ def to_dict(self) -> Dict[str, Any]:
59
+ result = {
60
+ "id": self.id,
61
+ "ai": self.ai,
62
+ "author_id": self.author_id,
63
+ "author_name": self.author_name,
64
+ "timestamp": self.timestamp,
65
+ "blocks": [block.to_dict() for block in self.blocks],
66
+ }
67
+ if self.consumption_metadata:
68
+ result["consumption_metadata"] = self.consumption_metadata
69
+ if self.attachments:
70
+ result["attachments"] = [att.to_dict() for att in self.attachments]
71
+ return result
72
+
73
+ def __repr__(self) -> str:
74
+ """Return a detailed string representation for debugging."""
75
+ blocks_info = []
76
+ for block in self.blocks:
77
+ block_repr = f"{block.type.value} ({block.id}, {block.streaming}):"
78
+ if block.content:
79
+ content_preview = block.content[:50] + "..." if len(block.content) > 50 else block.content
80
+ block_repr += f":{content_preview}"
81
+ if block.tool_name:
82
+ block_repr += f"[{block.tool_name}]"
83
+ blocks_info.append(block_repr)
84
+
85
+ return (
86
+ f"ThreadMessage(id='{self.id}', ai={self.ai}, "
87
+ f"author='{self.author_name}', timestamp={self.timestamp}, "
88
+ f"blocks=[{', '.join(blocks_info)}])"
89
+ )
90
+
91
+
92
+ @dataclass
93
+ class ToolCallResponse:
94
+ id: str
95
+ response: str
96
+ error: Optional[str] = None
97
+
98
+
99
+ # Event system
100
+ @dataclass
101
+ class ThreadEvent(ABC):
102
+ """Abstract base class for all thread events"""
103
+
104
+ def get_event_type(self) -> str:
105
+ """Return the event type identifier"""
106
+ return fix_event_type(self.__class__.__name__)
107
+
108
+ def get_event_data(self) -> Optional[Dict[str, Any]]:
109
+ """Return the event data."""
110
+ return None
111
+
112
+ def is_publishable(self) -> bool:
113
+ """Return True if the event is publishable."""
114
+ return False
115
+
116
+ def dump_json(self, thread_id: str) -> str:
117
+ return json.dumps(
118
+ {
119
+ "thread_id": thread_id,
120
+ "event_type": self.get_event_type(),
121
+ "timestamp": int(time.time() * 1000),
122
+ "data": self.get_event_data(),
123
+ }
124
+ )
125
+
126
+
127
+ @dataclass
128
+ class PublishableEvent(ThreadEvent):
129
+ """Abstract base class for all publishable events"""
130
+
131
+ def get_event_data(self) -> Optional[Dict[str, Any]]:
132
+ """Return the event data."""
133
+ return self.__dict__
134
+
135
+ def is_publishable(self) -> bool:
136
+ """Return True if the event is publishable."""
137
+ return True
138
+
139
+
140
+ @dataclass
141
+ class InternalEvent(ThreadEvent):
142
+ """Abstract base class for all publishable events"""
143
+
144
+ def get_event_data(self) -> Optional[Dict[str, Any]]:
145
+ return None
146
+
147
+ def is_publishable(self) -> bool:
148
+ return False
149
+
150
+
151
+ def fix_event_type(event_type: str) -> str:
152
+ """Fix the event type to be a valid event type"""
153
+ return event_type.replace("Event", "")
154
+
155
+
156
+ @dataclass
157
+ class StreamingUpdatedEvent(PublishableEvent):
158
+ block_id: str
159
+ content: str
160
+ total_content: str
161
+
162
+ def get_event_data(self) -> Optional[Dict[str, Any]]:
163
+ return {
164
+ "block_id": self.block_id,
165
+ "content": self.content,
166
+ }
167
+
168
+
169
+ @dataclass
170
+ class BlockAddedEvent(PublishableEvent):
171
+ message_id: str
172
+ block_id: str
173
+ block: MessageBlock
174
+
175
+ def get_event_data(self) -> Optional[Dict[str, Any]]:
176
+ return {"message_id": self.message_id, "block": self.block.to_dict()}
177
+
178
+
179
+ @dataclass
180
+ class ToolCallStartedEvent(InternalEvent):
181
+ tool_call_id: str
182
+ tool_name: str
183
+ message_id: str
184
+ block_id: str
185
+
186
+
187
+ @dataclass
188
+ class ToolResponseReceivedEvent(PublishableEvent):
189
+ tool_call_id: str
190
+ response: str
191
+ error: Optional[str] = None
192
+ block_id: Optional[str] = None
193
+
194
+
195
+ @dataclass
196
+ class StreamingEndedEvent(InternalEvent):
197
+ message_id: str
198
+ completed_blocks: List[str]
199
+
200
+
201
+ @dataclass
202
+ class MessageAddedEvent(PublishableEvent):
203
+ message: ThreadMessage
204
+
205
+ def get_event_data(self) -> Optional[Dict[str, Any]]:
206
+ return self.message.to_dict()
207
+
208
+
209
+ @dataclass
210
+ class MessageFullyAddedEvent(PublishableEvent):
211
+ message: ThreadMessage
212
+
213
+ def get_event_data(self) -> Optional[Dict[str, Any]]:
214
+ return {"message_id": self.message.id}
215
+
216
+
217
+ @dataclass
218
+ class BlockFullyAddedEvent(PublishableEvent):
219
+ block_id: str
220
+ message_id: str
221
+ block: MessageBlock
222
+
223
+ def get_event_data(self) -> Optional[Dict[str, Any]]:
224
+ return {"message_id": self.message_id, "block_id": self.block_id}