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.
- spaik_sdk/__init__.py +21 -0
- spaik_sdk/agent/__init__.py +0 -0
- spaik_sdk/agent/base_agent.py +249 -0
- spaik_sdk/attachments/__init__.py +22 -0
- spaik_sdk/attachments/builder.py +61 -0
- spaik_sdk/attachments/file_storage_provider.py +27 -0
- spaik_sdk/attachments/mime_types.py +118 -0
- spaik_sdk/attachments/models.py +63 -0
- spaik_sdk/attachments/provider_support.py +53 -0
- spaik_sdk/attachments/storage/__init__.py +0 -0
- spaik_sdk/attachments/storage/base_file_storage.py +32 -0
- spaik_sdk/attachments/storage/impl/__init__.py +0 -0
- spaik_sdk/attachments/storage/impl/local_file_storage.py +101 -0
- spaik_sdk/audio/__init__.py +12 -0
- spaik_sdk/audio/options.py +53 -0
- spaik_sdk/audio/providers/__init__.py +1 -0
- spaik_sdk/audio/providers/google_tts.py +77 -0
- spaik_sdk/audio/providers/openai_stt.py +71 -0
- spaik_sdk/audio/providers/openai_tts.py +111 -0
- spaik_sdk/audio/stt.py +61 -0
- spaik_sdk/audio/tts.py +124 -0
- spaik_sdk/config/credentials_provider.py +10 -0
- spaik_sdk/config/env.py +59 -0
- spaik_sdk/config/env_credentials_provider.py +7 -0
- spaik_sdk/config/get_credentials_provider.py +14 -0
- spaik_sdk/image_gen/__init__.py +9 -0
- spaik_sdk/image_gen/image_generator.py +83 -0
- spaik_sdk/image_gen/options.py +24 -0
- spaik_sdk/image_gen/providers/__init__.py +0 -0
- spaik_sdk/image_gen/providers/google.py +75 -0
- spaik_sdk/image_gen/providers/openai.py +60 -0
- spaik_sdk/llm/__init__.py +0 -0
- spaik_sdk/llm/cancellation_handle.py +10 -0
- spaik_sdk/llm/consumption/__init__.py +0 -0
- spaik_sdk/llm/consumption/consumption_estimate.py +26 -0
- spaik_sdk/llm/consumption/consumption_estimate_builder.py +113 -0
- spaik_sdk/llm/consumption/consumption_extractor.py +59 -0
- spaik_sdk/llm/consumption/token_usage.py +31 -0
- spaik_sdk/llm/converters.py +146 -0
- spaik_sdk/llm/cost/__init__.py +1 -0
- spaik_sdk/llm/cost/builtin_cost_provider.py +83 -0
- spaik_sdk/llm/cost/cost_estimate.py +8 -0
- spaik_sdk/llm/cost/cost_provider.py +28 -0
- spaik_sdk/llm/extract_error_message.py +37 -0
- spaik_sdk/llm/langchain_loop_manager.py +270 -0
- spaik_sdk/llm/langchain_service.py +196 -0
- spaik_sdk/llm/message_handler.py +188 -0
- spaik_sdk/llm/streaming/__init__.py +1 -0
- spaik_sdk/llm/streaming/block_manager.py +152 -0
- spaik_sdk/llm/streaming/models.py +42 -0
- spaik_sdk/llm/streaming/streaming_content_handler.py +157 -0
- spaik_sdk/llm/streaming/streaming_event_handler.py +215 -0
- spaik_sdk/llm/streaming/streaming_state_manager.py +58 -0
- spaik_sdk/models/__init__.py +0 -0
- spaik_sdk/models/factories/__init__.py +0 -0
- spaik_sdk/models/factories/anthropic_factory.py +33 -0
- spaik_sdk/models/factories/base_model_factory.py +71 -0
- spaik_sdk/models/factories/google_factory.py +30 -0
- spaik_sdk/models/factories/ollama_factory.py +41 -0
- spaik_sdk/models/factories/openai_factory.py +50 -0
- spaik_sdk/models/llm_config.py +46 -0
- spaik_sdk/models/llm_families.py +7 -0
- spaik_sdk/models/llm_model.py +17 -0
- spaik_sdk/models/llm_wrapper.py +25 -0
- spaik_sdk/models/model_registry.py +156 -0
- spaik_sdk/models/providers/__init__.py +0 -0
- spaik_sdk/models/providers/anthropic_provider.py +29 -0
- spaik_sdk/models/providers/azure_provider.py +31 -0
- spaik_sdk/models/providers/base_provider.py +62 -0
- spaik_sdk/models/providers/google_provider.py +26 -0
- spaik_sdk/models/providers/ollama_provider.py +26 -0
- spaik_sdk/models/providers/openai_provider.py +26 -0
- spaik_sdk/models/providers/provider_type.py +90 -0
- spaik_sdk/orchestration/__init__.py +24 -0
- spaik_sdk/orchestration/base_orchestrator.py +238 -0
- spaik_sdk/orchestration/checkpoint.py +80 -0
- spaik_sdk/orchestration/models.py +103 -0
- spaik_sdk/prompt/__init__.py +0 -0
- spaik_sdk/prompt/get_prompt_loader.py +13 -0
- spaik_sdk/prompt/local_prompt_loader.py +21 -0
- spaik_sdk/prompt/prompt_loader.py +48 -0
- spaik_sdk/prompt/prompt_loader_mode.py +14 -0
- spaik_sdk/py.typed +1 -0
- spaik_sdk/recording/__init__.py +1 -0
- spaik_sdk/recording/base_playback.py +90 -0
- spaik_sdk/recording/base_recorder.py +50 -0
- spaik_sdk/recording/conditional_recorder.py +38 -0
- spaik_sdk/recording/impl/__init__.py +1 -0
- spaik_sdk/recording/impl/local_playback.py +76 -0
- spaik_sdk/recording/impl/local_recorder.py +85 -0
- spaik_sdk/recording/langchain_serializer.py +88 -0
- spaik_sdk/server/__init__.py +1 -0
- spaik_sdk/server/api/routers/__init__.py +0 -0
- spaik_sdk/server/api/routers/api_builder.py +149 -0
- spaik_sdk/server/api/routers/audio_router_factory.py +201 -0
- spaik_sdk/server/api/routers/file_router_factory.py +111 -0
- spaik_sdk/server/api/routers/thread_router_factory.py +284 -0
- spaik_sdk/server/api/streaming/__init__.py +0 -0
- spaik_sdk/server/api/streaming/format_sse_event.py +41 -0
- spaik_sdk/server/api/streaming/negotiate_streaming_response.py +8 -0
- spaik_sdk/server/api/streaming/streaming_negotiator.py +10 -0
- spaik_sdk/server/authorization/__init__.py +0 -0
- spaik_sdk/server/authorization/base_authorizer.py +64 -0
- spaik_sdk/server/authorization/base_user.py +13 -0
- spaik_sdk/server/authorization/dummy_authorizer.py +17 -0
- spaik_sdk/server/job_processor/__init__.py +0 -0
- spaik_sdk/server/job_processor/base_job_processor.py +8 -0
- spaik_sdk/server/job_processor/thread_job_processor.py +32 -0
- spaik_sdk/server/pubsub/__init__.py +1 -0
- spaik_sdk/server/pubsub/cancellation_publisher.py +7 -0
- spaik_sdk/server/pubsub/cancellation_subscriber.py +38 -0
- spaik_sdk/server/pubsub/event_publisher.py +13 -0
- spaik_sdk/server/pubsub/impl/__init__.py +1 -0
- spaik_sdk/server/pubsub/impl/local_cancellation_pubsub.py +48 -0
- spaik_sdk/server/pubsub/impl/signalr_publisher.py +36 -0
- spaik_sdk/server/queue/__init__.py +1 -0
- spaik_sdk/server/queue/agent_job_queue.py +27 -0
- spaik_sdk/server/queue/impl/__init__.py +1 -0
- spaik_sdk/server/queue/impl/azure_queue.py +24 -0
- spaik_sdk/server/response/__init__.py +0 -0
- spaik_sdk/server/response/agent_response_generator.py +39 -0
- spaik_sdk/server/response/response_generator.py +13 -0
- spaik_sdk/server/response/simple_agent_response_generator.py +14 -0
- spaik_sdk/server/services/__init__.py +0 -0
- spaik_sdk/server/services/thread_converters.py +113 -0
- spaik_sdk/server/services/thread_models.py +90 -0
- spaik_sdk/server/services/thread_service.py +91 -0
- spaik_sdk/server/storage/__init__.py +1 -0
- spaik_sdk/server/storage/base_thread_repository.py +51 -0
- spaik_sdk/server/storage/impl/__init__.py +0 -0
- spaik_sdk/server/storage/impl/in_memory_thread_repository.py +100 -0
- spaik_sdk/server/storage/impl/local_file_thread_repository.py +217 -0
- spaik_sdk/server/storage/thread_filter.py +166 -0
- spaik_sdk/server/storage/thread_metadata.py +53 -0
- spaik_sdk/thread/__init__.py +0 -0
- spaik_sdk/thread/adapters/__init__.py +0 -0
- spaik_sdk/thread/adapters/cli/__init__.py +0 -0
- spaik_sdk/thread/adapters/cli/block_display.py +92 -0
- spaik_sdk/thread/adapters/cli/display_manager.py +84 -0
- spaik_sdk/thread/adapters/cli/live_cli.py +235 -0
- spaik_sdk/thread/adapters/event_adapter.py +28 -0
- spaik_sdk/thread/adapters/streaming_block_adapter.py +57 -0
- spaik_sdk/thread/adapters/sync_adapter.py +76 -0
- spaik_sdk/thread/models.py +224 -0
- spaik_sdk/thread/thread_container.py +468 -0
- spaik_sdk/tools/__init__.py +0 -0
- spaik_sdk/tools/impl/__init__.py +0 -0
- spaik_sdk/tools/impl/mcp_tool_provider.py +93 -0
- spaik_sdk/tools/impl/search_tool_provider.py +18 -0
- spaik_sdk/tools/tool_provider.py +131 -0
- spaik_sdk/tracing/__init__.py +13 -0
- spaik_sdk/tracing/agent_trace.py +72 -0
- spaik_sdk/tracing/get_trace_sink.py +15 -0
- spaik_sdk/tracing/local_trace_sink.py +23 -0
- spaik_sdk/tracing/trace_sink.py +19 -0
- spaik_sdk/tracing/trace_sink_mode.py +14 -0
- spaik_sdk/utils/__init__.py +0 -0
- spaik_sdk/utils/init_logger.py +24 -0
- spaik_sdk-0.6.2.dist-info/METADATA +379 -0
- spaik_sdk-0.6.2.dist-info/RECORD +161 -0
- 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}
|