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,38 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from spaik_sdk.recording.base_playback import BasePlayback
|
|
5
|
+
from spaik_sdk.recording.base_recorder import BaseRecorder
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConditionalRecorderMode(Enum):
|
|
9
|
+
"""Mode for ConditionalRecorder behavior."""
|
|
10
|
+
|
|
11
|
+
ALWAYS_RECORD = "always_record"
|
|
12
|
+
ALWAYS_PLAYBACK = "always_playback"
|
|
13
|
+
AUTO = "auto"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConditionalRecorder:
|
|
17
|
+
"""Conditional recorder that switches between recording and playback based on mode."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, recorder: BaseRecorder, playback: BasePlayback, mode: ConditionalRecorderMode = ConditionalRecorderMode.AUTO):
|
|
20
|
+
self.recorder = recorder
|
|
21
|
+
self.playback = playback
|
|
22
|
+
self.mode = mode
|
|
23
|
+
|
|
24
|
+
def get_recorder(self) -> Optional[BaseRecorder]:
|
|
25
|
+
"""Returns the recorder if should record, None otherwise."""
|
|
26
|
+
if self.mode == ConditionalRecorderMode.ALWAYS_RECORD:
|
|
27
|
+
return self.recorder
|
|
28
|
+
elif self.mode == ConditionalRecorderMode.AUTO and not self.playback.is_available():
|
|
29
|
+
return self.recorder
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
def get_playback(self) -> Optional[BasePlayback]:
|
|
33
|
+
"""Returns the playback if should playback, None otherwise."""
|
|
34
|
+
if self.mode == ConditionalRecorderMode.ALWAYS_PLAYBACK:
|
|
35
|
+
return self.playback
|
|
36
|
+
elif self.mode == ConditionalRecorderMode.AUTO and self.playback.is_available():
|
|
37
|
+
return self.playback
|
|
38
|
+
return None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any, Dict, Iterator
|
|
4
|
+
|
|
5
|
+
from spaik_sdk.recording.base_playback import BasePlayback
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LocalPlayback(BasePlayback):
|
|
9
|
+
"""Local file implementation of BasePlayback."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, recording_name: str = "default", recordings_dir: str = "recordings", delay: float = 0.001):
|
|
12
|
+
super().__init__(recording_name, delay)
|
|
13
|
+
self.base_recordings_dir = Path(recordings_dir)
|
|
14
|
+
self.recordings_dir = self.base_recordings_dir / self.recording_name
|
|
15
|
+
|
|
16
|
+
def _get_streaming_file_path(self, session: int) -> Path:
|
|
17
|
+
"""Get path for streaming tokens file."""
|
|
18
|
+
return self.recordings_dir / f"{session}.jsonl"
|
|
19
|
+
|
|
20
|
+
def _get_structured_file_path(self, session: int) -> Path:
|
|
21
|
+
"""Get path for structured response file."""
|
|
22
|
+
return self.recordings_dir / f"{session}.json"
|
|
23
|
+
|
|
24
|
+
def _session_exists(self, session_num: int) -> bool:
|
|
25
|
+
"""Check if either streaming or structured file exists for session."""
|
|
26
|
+
streaming_path = self._get_streaming_file_path(session_num)
|
|
27
|
+
structured_path = self._get_structured_file_path(session_num)
|
|
28
|
+
return streaming_path.exists() or structured_path.exists()
|
|
29
|
+
|
|
30
|
+
def is_available(self) -> bool:
|
|
31
|
+
"""Check if playback data is available."""
|
|
32
|
+
return self.recordings_dir.exists() and any(self.recordings_dir.iterdir())
|
|
33
|
+
|
|
34
|
+
def _load_session_data_impl(self, session_num: int) -> Iterator[Dict[str, Any]]:
|
|
35
|
+
"""Load raw data for a specific session number."""
|
|
36
|
+
streaming_path = self._get_streaming_file_path(session_num)
|
|
37
|
+
structured_path = self._get_structured_file_path(session_num)
|
|
38
|
+
|
|
39
|
+
# Check for structured response first (single response)
|
|
40
|
+
if structured_path.exists():
|
|
41
|
+
with open(structured_path, "r", encoding="utf-8") as f:
|
|
42
|
+
data = json.load(f)
|
|
43
|
+
yield data
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
# Check for streaming tokens (.jsonl)
|
|
47
|
+
if streaming_path.exists():
|
|
48
|
+
with open(streaming_path, "r", encoding="utf-8") as f:
|
|
49
|
+
for line in f:
|
|
50
|
+
line = line.strip()
|
|
51
|
+
if line: # Skip empty lines
|
|
52
|
+
try:
|
|
53
|
+
token_data = json.loads(line)
|
|
54
|
+
yield token_data
|
|
55
|
+
except json.JSONDecodeError:
|
|
56
|
+
# Skip malformed lines
|
|
57
|
+
continue
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
# No files found for this session
|
|
61
|
+
raise StopIteration(f"No data found for session {session_num}")
|
|
62
|
+
|
|
63
|
+
def peek_next_session_type(self) -> str:
|
|
64
|
+
"""Peek at what type the next session will be without consuming it."""
|
|
65
|
+
if not self._session_exists(self.current_session):
|
|
66
|
+
return "none"
|
|
67
|
+
|
|
68
|
+
structured_path = self._get_structured_file_path(self.current_session)
|
|
69
|
+
if structured_path.exists():
|
|
70
|
+
return "structured"
|
|
71
|
+
|
|
72
|
+
streaming_path = self._get_streaming_file_path(self.current_session)
|
|
73
|
+
if streaming_path.exists():
|
|
74
|
+
return "streaming"
|
|
75
|
+
|
|
76
|
+
return "none"
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any, Dict, Optional, TextIO
|
|
4
|
+
|
|
5
|
+
from spaik_sdk.recording.base_recorder import BaseRecorder
|
|
6
|
+
from spaik_sdk.recording.conditional_recorder import ConditionalRecorder, ConditionalRecorderMode
|
|
7
|
+
from spaik_sdk.recording.impl.local_playback import LocalPlayback
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LocalRecorder(BaseRecorder):
|
|
11
|
+
"""Local file implementation of BaseRecorder."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, recording_name: str = "default", recordings_dir: str = "recordings"):
|
|
14
|
+
super().__init__(recording_name)
|
|
15
|
+
self.base_recordings_dir = Path(recordings_dir)
|
|
16
|
+
self.recordings_dir = self.base_recordings_dir / self.recording_name
|
|
17
|
+
self.recordings_dir.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
self._current_file_handle: Optional[TextIO] = None
|
|
19
|
+
|
|
20
|
+
def _get_streaming_file_path(self, session: int) -> Path:
|
|
21
|
+
"""Get path for streaming tokens file."""
|
|
22
|
+
return self.recordings_dir / f"{session}.jsonl"
|
|
23
|
+
|
|
24
|
+
def _get_structured_file_path(self, session: int) -> Path:
|
|
25
|
+
"""Get path for structured response file."""
|
|
26
|
+
return self.recordings_dir / f"{session}.json"
|
|
27
|
+
|
|
28
|
+
def _ensure_streaming_file_open(self) -> None:
|
|
29
|
+
"""Ensure the current streaming file is open for writing."""
|
|
30
|
+
if self._current_file_handle is None:
|
|
31
|
+
file_path = self._get_streaming_file_path(self.current_session)
|
|
32
|
+
self._current_file_handle = open(file_path, "a", encoding="utf-8")
|
|
33
|
+
|
|
34
|
+
def _close_current_file(self) -> None:
|
|
35
|
+
"""Close the current file handle if open."""
|
|
36
|
+
if self._current_file_handle:
|
|
37
|
+
self._current_file_handle.close()
|
|
38
|
+
self._current_file_handle = None
|
|
39
|
+
|
|
40
|
+
def _record_token_impl(self, token_data: Dict[str, Any]) -> None:
|
|
41
|
+
"""Record a streaming token to the current .jsonl file."""
|
|
42
|
+
self._ensure_streaming_file_open()
|
|
43
|
+
json_line = json.dumps(token_data, ensure_ascii=False)
|
|
44
|
+
if self._current_file_handle:
|
|
45
|
+
self._current_file_handle.write(json_line + "\n")
|
|
46
|
+
self._current_file_handle.flush()
|
|
47
|
+
|
|
48
|
+
def _record_structured_impl(self, data: Dict[str, Any]) -> None:
|
|
49
|
+
"""Record structured response to .json file and bump session."""
|
|
50
|
+
# Close any open streaming file
|
|
51
|
+
self._close_current_file()
|
|
52
|
+
|
|
53
|
+
# Write structured data to .json file
|
|
54
|
+
file_path = self._get_structured_file_path(self.current_session)
|
|
55
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
56
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
57
|
+
|
|
58
|
+
# Immediately bump to next session
|
|
59
|
+
self.current_session += 1
|
|
60
|
+
|
|
61
|
+
def request_completed(self) -> None:
|
|
62
|
+
"""Close current file and bump to next session."""
|
|
63
|
+
self._close_current_file()
|
|
64
|
+
self.current_session += 1
|
|
65
|
+
|
|
66
|
+
def get_current_session(self) -> int:
|
|
67
|
+
"""Get the current session number."""
|
|
68
|
+
return self.current_session
|
|
69
|
+
|
|
70
|
+
def __del__(self):
|
|
71
|
+
"""Cleanup: close any open file handles."""
|
|
72
|
+
self._close_current_file()
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def create_conditional_recorder(
|
|
76
|
+
cls,
|
|
77
|
+
recording_name: str = "default",
|
|
78
|
+
recordings_dir: str = "recordings",
|
|
79
|
+
mode: ConditionalRecorderMode = ConditionalRecorderMode.AUTO,
|
|
80
|
+
delay: float = 0.001,
|
|
81
|
+
) -> ConditionalRecorder:
|
|
82
|
+
"""Create a conditional recorder with a local recorder and playback."""
|
|
83
|
+
recorder = cls(recording_name, recordings_dir)
|
|
84
|
+
playback = LocalPlayback(recording_name, recordings_dir, delay)
|
|
85
|
+
return ConditionalRecorder(recorder, playback, mode)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import warnings
|
|
2
|
+
from typing import Any, Dict
|
|
3
|
+
|
|
4
|
+
from langchain_core.load import dumpd, load
|
|
5
|
+
from langchain_core.messages.base import BaseMessage
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def serialize_token_data(token_data: Any) -> Dict[str, Any]:
|
|
9
|
+
"""Serialize token data that may contain LangChain objects."""
|
|
10
|
+
try:
|
|
11
|
+
# Try to serialize using LangChain's built-in serialization
|
|
12
|
+
return dumpd(token_data)
|
|
13
|
+
except Exception:
|
|
14
|
+
# Fallback: handle individual components
|
|
15
|
+
if isinstance(token_data, dict):
|
|
16
|
+
serialized = {}
|
|
17
|
+
for key, value in token_data.items():
|
|
18
|
+
try:
|
|
19
|
+
serialized[key] = dumpd(value)
|
|
20
|
+
except Exception:
|
|
21
|
+
# For non-serializable values, convert to string representation
|
|
22
|
+
if isinstance(value, BaseMessage):
|
|
23
|
+
serialized[key] = {
|
|
24
|
+
"type": "langchain_message_fallback",
|
|
25
|
+
"message_type": value.type,
|
|
26
|
+
"content": value.content,
|
|
27
|
+
"additional_kwargs": value.additional_kwargs,
|
|
28
|
+
"id": value.id,
|
|
29
|
+
"name": getattr(value, "name", None),
|
|
30
|
+
}
|
|
31
|
+
else:
|
|
32
|
+
serialized[key] = {"type": "fallback", "data": str(value)}
|
|
33
|
+
return serialized
|
|
34
|
+
else:
|
|
35
|
+
# For non-dict objects, try to convert to string
|
|
36
|
+
return {"type": "fallback", "data": str(token_data)}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def deserialize_token_data(data: Dict[str, Any]) -> Any:
|
|
40
|
+
"""Deserialize token data that may contain LangChain objects."""
|
|
41
|
+
try:
|
|
42
|
+
# Try LangChain's built-in deserialization
|
|
43
|
+
with warnings.catch_warnings():
|
|
44
|
+
warnings.filterwarnings("ignore", message=".*is in beta.*")
|
|
45
|
+
return load(data)
|
|
46
|
+
except Exception:
|
|
47
|
+
# Fallback: handle individual components
|
|
48
|
+
if isinstance(data, dict):
|
|
49
|
+
deserialized = {}
|
|
50
|
+
for key, value in data.items():
|
|
51
|
+
if isinstance(value, dict):
|
|
52
|
+
try:
|
|
53
|
+
with warnings.catch_warnings():
|
|
54
|
+
warnings.filterwarnings("ignore", message=".*is in beta.*")
|
|
55
|
+
deserialized[key] = load(value)
|
|
56
|
+
except Exception:
|
|
57
|
+
# Check for fallback message format
|
|
58
|
+
if value.get("type") == "langchain_message_fallback":
|
|
59
|
+
# Reconstruct basic message info (will lose some functionality)
|
|
60
|
+
deserialized[key] = {
|
|
61
|
+
"type": value["message_type"],
|
|
62
|
+
"content": value["content"],
|
|
63
|
+
"additional_kwargs": value.get("additional_kwargs", {}),
|
|
64
|
+
"id": value.get("id"),
|
|
65
|
+
"name": value.get("name"),
|
|
66
|
+
}
|
|
67
|
+
elif value.get("type") == "fallback":
|
|
68
|
+
deserialized[key] = value["data"]
|
|
69
|
+
else:
|
|
70
|
+
deserialized[key] = value
|
|
71
|
+
else:
|
|
72
|
+
deserialized[key] = value
|
|
73
|
+
return deserialized
|
|
74
|
+
else:
|
|
75
|
+
return data
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def ensure_json_serializable(obj: Any) -> Any:
|
|
79
|
+
"""Ensure an object is JSON serializable by converting problematic types."""
|
|
80
|
+
if obj is None or isinstance(obj, (str, int, float, bool)):
|
|
81
|
+
return obj
|
|
82
|
+
elif isinstance(obj, dict):
|
|
83
|
+
return {k: ensure_json_serializable(v) for k, v in obj.items()}
|
|
84
|
+
elif isinstance(obj, list):
|
|
85
|
+
return [ensure_json_serializable(item) for item in obj]
|
|
86
|
+
else:
|
|
87
|
+
# Convert other types to string
|
|
88
|
+
return str(obj)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
File without changes
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from typing import Awaitable, Optional
|
|
3
|
+
|
|
4
|
+
from fastapi import APIRouter
|
|
5
|
+
|
|
6
|
+
from spaik_sdk.agent.base_agent import BaseAgent
|
|
7
|
+
from spaik_sdk.attachments.file_storage_provider import set_file_storage
|
|
8
|
+
from spaik_sdk.attachments.storage.base_file_storage import BaseFileStorage
|
|
9
|
+
from spaik_sdk.attachments.storage.impl.local_file_storage import LocalFileStorage
|
|
10
|
+
from spaik_sdk.server.api.routers.audio_router_factory import AudioRouterFactory
|
|
11
|
+
from spaik_sdk.server.api.routers.file_router_factory import FileRouterFactory
|
|
12
|
+
from spaik_sdk.server.api.routers.thread_router_factory import ThreadRouterFactory
|
|
13
|
+
from spaik_sdk.server.api.streaming.streaming_negotiator import StreamingNegotiator
|
|
14
|
+
from spaik_sdk.server.authorization.base_authorizer import BaseAuthorizer
|
|
15
|
+
from spaik_sdk.server.authorization.base_user import BaseUser
|
|
16
|
+
from spaik_sdk.server.authorization.dummy_authorizer import DummyAuthorizer
|
|
17
|
+
from spaik_sdk.server.job_processor.thread_job_processor import ThreadJobProcessor
|
|
18
|
+
from spaik_sdk.server.pubsub.cancellation_publisher import CancellationPublisher
|
|
19
|
+
from spaik_sdk.server.pubsub.cancellation_subscriber import CancellationSubscriber
|
|
20
|
+
from spaik_sdk.server.pubsub.impl.local_cancellation_pubsub import get_local_cancellation_pubsub
|
|
21
|
+
from spaik_sdk.server.queue.agent_job_queue import AgentJobQueue
|
|
22
|
+
from spaik_sdk.server.response.response_generator import ResponseGenerator
|
|
23
|
+
from spaik_sdk.server.response.simple_agent_response_generator import SimpleAgentResponseGenerator
|
|
24
|
+
from spaik_sdk.server.services.thread_service import ThreadService
|
|
25
|
+
from spaik_sdk.server.storage.base_thread_repository import BaseThreadRepository
|
|
26
|
+
from spaik_sdk.server.storage.impl.in_memory_thread_repository import InMemoryThreadRepository
|
|
27
|
+
from spaik_sdk.server.storage.impl.local_file_thread_repository import LocalFileThreadRepository
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ApiBuilder:
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
repository: BaseThreadRepository,
|
|
34
|
+
authorizer: Optional[BaseAuthorizer[BaseUser]] = None,
|
|
35
|
+
streaming_negotiator: Optional[StreamingNegotiator] = None,
|
|
36
|
+
job_queue: Optional[AgentJobQueue] = None,
|
|
37
|
+
cancellation_subscriber_provider: Optional[Callable[[str], Awaitable[CancellationSubscriber]]] = None,
|
|
38
|
+
cancellation_publisher: Optional[CancellationPublisher] = None,
|
|
39
|
+
response_generator: Optional[ResponseGenerator] = None,
|
|
40
|
+
agent: Optional[BaseAgent] = None,
|
|
41
|
+
file_storage: Optional[BaseFileStorage] = None,
|
|
42
|
+
):
|
|
43
|
+
self.repository = repository
|
|
44
|
+
self.thread_service = ThreadService(repository)
|
|
45
|
+
self.authorizer = authorizer
|
|
46
|
+
self.streaming_negotiator = streaming_negotiator
|
|
47
|
+
self.job_queue = job_queue
|
|
48
|
+
self.cancellation_subscriber_provider = cancellation_subscriber_provider
|
|
49
|
+
self.cancellation_publisher = cancellation_publisher
|
|
50
|
+
self.file_storage = file_storage
|
|
51
|
+
if not response_generator and agent:
|
|
52
|
+
self.response_generator: Optional[ResponseGenerator] = SimpleAgentResponseGenerator(agent)
|
|
53
|
+
else:
|
|
54
|
+
self.response_generator = response_generator
|
|
55
|
+
|
|
56
|
+
def build_file_router(self) -> APIRouter:
|
|
57
|
+
if not self.file_storage:
|
|
58
|
+
raise ValueError("File storage is required for file router")
|
|
59
|
+
factory = FileRouterFactory(
|
|
60
|
+
file_storage=self.file_storage,
|
|
61
|
+
authorizer=self.authorizer,
|
|
62
|
+
)
|
|
63
|
+
return factory.create_router()
|
|
64
|
+
|
|
65
|
+
def build_audio_router(
|
|
66
|
+
self,
|
|
67
|
+
tts_model: Optional[str] = None,
|
|
68
|
+
stt_model: Optional[str] = None,
|
|
69
|
+
) -> APIRouter:
|
|
70
|
+
"""
|
|
71
|
+
Build the audio router for TTS/STT endpoints.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
tts_model: Default TTS model (e.g., "tts-1", "gemini-2.5-flash-tts")
|
|
75
|
+
stt_model: Default STT model (e.g., "whisper-1")
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
FastAPI router with /audio/speech and /audio/transcribe endpoints
|
|
79
|
+
"""
|
|
80
|
+
factory = AudioRouterFactory(
|
|
81
|
+
authorizer=self.authorizer,
|
|
82
|
+
tts_model=tts_model,
|
|
83
|
+
stt_model=stt_model,
|
|
84
|
+
)
|
|
85
|
+
return factory.create_router()
|
|
86
|
+
|
|
87
|
+
def build_thread_router(self) -> APIRouter:
|
|
88
|
+
if not self.response_generator:
|
|
89
|
+
raise ValueError("Response generator or agent is required")
|
|
90
|
+
|
|
91
|
+
job_processor = ThreadJobProcessor(thread_service=self.thread_service, response_generator=self.response_generator)
|
|
92
|
+
factory = ThreadRouterFactory(
|
|
93
|
+
service=self.thread_service,
|
|
94
|
+
authorizer=self.authorizer,
|
|
95
|
+
streaming_negotiator=self.streaming_negotiator,
|
|
96
|
+
job_queue=self.job_queue,
|
|
97
|
+
thread_job_processor=job_processor,
|
|
98
|
+
cancellation_subscriber_provider=self.cancellation_subscriber_provider,
|
|
99
|
+
cancellation_publisher=self.cancellation_publisher,
|
|
100
|
+
)
|
|
101
|
+
return factory.create_router()
|
|
102
|
+
|
|
103
|
+
@classmethod
|
|
104
|
+
def stateful(
|
|
105
|
+
cls,
|
|
106
|
+
repository: BaseThreadRepository,
|
|
107
|
+
authorizer: BaseAuthorizer[BaseUser],
|
|
108
|
+
agent: Optional[BaseAgent] = None,
|
|
109
|
+
response_generator: Optional[ResponseGenerator] = None,
|
|
110
|
+
file_storage: Optional[BaseFileStorage] = None,
|
|
111
|
+
) -> "ApiBuilder":
|
|
112
|
+
cancellation_pubsub = get_local_cancellation_pubsub()
|
|
113
|
+
|
|
114
|
+
async def cancellation_subscriber_provider(id: str) -> CancellationSubscriber:
|
|
115
|
+
return cancellation_pubsub.create_subscriber(id)
|
|
116
|
+
|
|
117
|
+
# Set the singleton if file_storage is provided
|
|
118
|
+
if file_storage is not None:
|
|
119
|
+
set_file_storage(file_storage)
|
|
120
|
+
|
|
121
|
+
return ApiBuilder(
|
|
122
|
+
repository=repository,
|
|
123
|
+
authorizer=authorizer,
|
|
124
|
+
cancellation_subscriber_provider=cancellation_subscriber_provider,
|
|
125
|
+
cancellation_publisher=cancellation_pubsub.get_publisher(),
|
|
126
|
+
agent=agent,
|
|
127
|
+
response_generator=response_generator,
|
|
128
|
+
file_storage=file_storage,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
@classmethod
|
|
132
|
+
def local(
|
|
133
|
+
cls,
|
|
134
|
+
agent: Optional[BaseAgent] = None,
|
|
135
|
+
response_generator: Optional[ResponseGenerator] = None,
|
|
136
|
+
in_memory: bool = False,
|
|
137
|
+
file_storage: Optional[BaseFileStorage] = None,
|
|
138
|
+
) -> "ApiBuilder":
|
|
139
|
+
# Use provided file_storage or create a local one
|
|
140
|
+
storage = file_storage or LocalFileStorage()
|
|
141
|
+
# Also set the singleton so LangChainService can access it
|
|
142
|
+
set_file_storage(storage)
|
|
143
|
+
return cls.stateful(
|
|
144
|
+
repository=InMemoryThreadRepository() if in_memory else LocalFileThreadRepository(),
|
|
145
|
+
authorizer=DummyAuthorizer(),
|
|
146
|
+
agent=agent,
|
|
147
|
+
response_generator=response_generator,
|
|
148
|
+
file_storage=storage,
|
|
149
|
+
)
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
|
4
|
+
from fastapi.responses import Response, StreamingResponse
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from spaik_sdk.audio import AudioFormat, SpeechToText, STTOptions, TextToSpeech, TTSOptions
|
|
8
|
+
from spaik_sdk.server.authorization.base_authorizer import BaseAuthorizer
|
|
9
|
+
from spaik_sdk.server.authorization.base_user import BaseUser
|
|
10
|
+
from spaik_sdk.utils.init_logger import init_logger
|
|
11
|
+
|
|
12
|
+
logger = init_logger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TTSRequest(BaseModel):
|
|
16
|
+
"""Request body for text-to-speech synthesis."""
|
|
17
|
+
|
|
18
|
+
text: str
|
|
19
|
+
model: Optional[str] = None
|
|
20
|
+
voice: str = "alloy"
|
|
21
|
+
speed: float = 1.0
|
|
22
|
+
format: str = "mp3"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class STTResponse(BaseModel):
|
|
26
|
+
"""Response from speech-to-text transcription."""
|
|
27
|
+
|
|
28
|
+
text: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AudioRouterFactory:
|
|
32
|
+
"""Factory for creating audio API routes (TTS/STT)."""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
authorizer: Optional[BaseAuthorizer[BaseUser]] = None,
|
|
37
|
+
tts_model: Optional[str] = None,
|
|
38
|
+
stt_model: Optional[str] = None,
|
|
39
|
+
):
|
|
40
|
+
self.authorizer = authorizer
|
|
41
|
+
self.tts_model = tts_model
|
|
42
|
+
self.stt_model = stt_model
|
|
43
|
+
|
|
44
|
+
def create_router(self, prefix: str = "/audio") -> APIRouter:
|
|
45
|
+
router = APIRouter(prefix=prefix, tags=["audio"])
|
|
46
|
+
|
|
47
|
+
async def get_current_user(request: Request) -> BaseUser:
|
|
48
|
+
if self.authorizer is None:
|
|
49
|
+
return BaseUser("anonymous")
|
|
50
|
+
user = await self.authorizer.get_user(request)
|
|
51
|
+
if not user:
|
|
52
|
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
|
53
|
+
return user
|
|
54
|
+
|
|
55
|
+
@router.post("/speech")
|
|
56
|
+
async def text_to_speech(
|
|
57
|
+
request: TTSRequest,
|
|
58
|
+
user: BaseUser = Depends(get_current_user),
|
|
59
|
+
):
|
|
60
|
+
"""
|
|
61
|
+
Convert text to speech audio.
|
|
62
|
+
|
|
63
|
+
Returns audio bytes in the specified format (default: mp3).
|
|
64
|
+
"""
|
|
65
|
+
try:
|
|
66
|
+
# Map format string to enum
|
|
67
|
+
format_map = {
|
|
68
|
+
"mp3": AudioFormat.MP3,
|
|
69
|
+
"opus": AudioFormat.OPUS,
|
|
70
|
+
"aac": AudioFormat.AAC,
|
|
71
|
+
"flac": AudioFormat.FLAC,
|
|
72
|
+
"wav": AudioFormat.WAV,
|
|
73
|
+
"pcm": AudioFormat.PCM,
|
|
74
|
+
}
|
|
75
|
+
output_format = format_map.get(request.format.lower(), AudioFormat.MP3)
|
|
76
|
+
|
|
77
|
+
options = TTSOptions(
|
|
78
|
+
voice=request.voice,
|
|
79
|
+
speed=request.speed,
|
|
80
|
+
output_format=output_format,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
tts = TextToSpeech(model=request.model or self.tts_model)
|
|
84
|
+
audio_bytes = await tts.synthesize(text=request.text, options=options)
|
|
85
|
+
|
|
86
|
+
# Determine content type
|
|
87
|
+
content_type_map = {
|
|
88
|
+
AudioFormat.MP3: "audio/mpeg",
|
|
89
|
+
AudioFormat.OPUS: "audio/opus",
|
|
90
|
+
AudioFormat.AAC: "audio/aac",
|
|
91
|
+
AudioFormat.FLAC: "audio/flac",
|
|
92
|
+
AudioFormat.WAV: "audio/wav",
|
|
93
|
+
AudioFormat.PCM: "audio/pcm",
|
|
94
|
+
}
|
|
95
|
+
content_type = content_type_map.get(output_format, "audio/mpeg")
|
|
96
|
+
|
|
97
|
+
return Response(
|
|
98
|
+
content=audio_bytes,
|
|
99
|
+
media_type=content_type,
|
|
100
|
+
headers={
|
|
101
|
+
"Content-Disposition": f'inline; filename="speech.{request.format}"',
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
except Exception as e:
|
|
105
|
+
logger.error(f"TTS error: {e}")
|
|
106
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
107
|
+
|
|
108
|
+
@router.post("/speech/stream")
|
|
109
|
+
async def text_to_speech_stream(
|
|
110
|
+
request: TTSRequest,
|
|
111
|
+
user: BaseUser = Depends(get_current_user),
|
|
112
|
+
):
|
|
113
|
+
"""
|
|
114
|
+
Stream text to speech audio.
|
|
115
|
+
|
|
116
|
+
Streams audio chunks as they are generated, allowing playback to start immediately.
|
|
117
|
+
This is faster for the user as audio begins playing before full generation is complete.
|
|
118
|
+
"""
|
|
119
|
+
try:
|
|
120
|
+
# Map format string to enum
|
|
121
|
+
format_map = {
|
|
122
|
+
"mp3": AudioFormat.MP3,
|
|
123
|
+
"opus": AudioFormat.OPUS,
|
|
124
|
+
"aac": AudioFormat.AAC,
|
|
125
|
+
"flac": AudioFormat.FLAC,
|
|
126
|
+
"wav": AudioFormat.WAV,
|
|
127
|
+
"pcm": AudioFormat.PCM,
|
|
128
|
+
}
|
|
129
|
+
output_format = format_map.get(request.format.lower(), AudioFormat.MP3)
|
|
130
|
+
|
|
131
|
+
options = TTSOptions(
|
|
132
|
+
voice=request.voice,
|
|
133
|
+
speed=request.speed,
|
|
134
|
+
output_format=output_format,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
tts = TextToSpeech(model=request.model or self.tts_model)
|
|
138
|
+
|
|
139
|
+
# Determine content type
|
|
140
|
+
content_type_map = {
|
|
141
|
+
AudioFormat.MP3: "audio/mpeg",
|
|
142
|
+
AudioFormat.OPUS: "audio/opus",
|
|
143
|
+
AudioFormat.AAC: "audio/aac",
|
|
144
|
+
AudioFormat.FLAC: "audio/flac",
|
|
145
|
+
AudioFormat.WAV: "audio/wav",
|
|
146
|
+
AudioFormat.PCM: "audio/pcm",
|
|
147
|
+
}
|
|
148
|
+
content_type = content_type_map.get(output_format, "audio/mpeg")
|
|
149
|
+
|
|
150
|
+
async def generate():
|
|
151
|
+
async for chunk in tts.synthesize_stream(text=request.text, options=options):
|
|
152
|
+
yield chunk
|
|
153
|
+
|
|
154
|
+
return StreamingResponse(
|
|
155
|
+
generate(),
|
|
156
|
+
media_type=content_type,
|
|
157
|
+
headers={
|
|
158
|
+
"Content-Disposition": f'inline; filename="speech.{request.format}"',
|
|
159
|
+
"Cache-Control": "no-cache",
|
|
160
|
+
},
|
|
161
|
+
)
|
|
162
|
+
except Exception as e:
|
|
163
|
+
logger.error(f"TTS stream error: {e}")
|
|
164
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
165
|
+
|
|
166
|
+
@router.post("/transcribe", response_model=STTResponse)
|
|
167
|
+
async def speech_to_text(
|
|
168
|
+
file: UploadFile = File(...),
|
|
169
|
+
language: Optional[str] = Form(None),
|
|
170
|
+
prompt: Optional[str] = Form(None),
|
|
171
|
+
user: BaseUser = Depends(get_current_user),
|
|
172
|
+
):
|
|
173
|
+
"""
|
|
174
|
+
Transcribe audio file to text using OpenAI Whisper.
|
|
175
|
+
|
|
176
|
+
Accepts audio files in various formats (webm, mp3, wav, m4a, ogg).
|
|
177
|
+
"""
|
|
178
|
+
try:
|
|
179
|
+
audio_bytes = await file.read()
|
|
180
|
+
filename = file.filename or "audio.webm"
|
|
181
|
+
|
|
182
|
+
logger.info(f"STT request: language={language}, filename={filename}, size={len(audio_bytes)}")
|
|
183
|
+
|
|
184
|
+
options = STTOptions(
|
|
185
|
+
language=language,
|
|
186
|
+
prompt=prompt,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
stt = SpeechToText(model=self.stt_model)
|
|
190
|
+
text = await stt.transcribe(
|
|
191
|
+
audio_bytes=audio_bytes,
|
|
192
|
+
options=options,
|
|
193
|
+
filename=filename,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
return STTResponse(text=text)
|
|
197
|
+
except Exception as e:
|
|
198
|
+
logger.error(f"STT error: {e}")
|
|
199
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
200
|
+
|
|
201
|
+
return router
|