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