orbitalsai 1.1.0__py3-none-any.whl → 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,245 @@
1
+ """
2
+ OrbitalsAI Streaming Protocol
3
+
4
+ Message encoding/decoding for the streaming WebSocket protocol.
5
+ """
6
+
7
+ import json
8
+ import logging
9
+ from dataclasses import dataclass, field
10
+ from enum import Enum
11
+ from typing import Any, Dict, List, Optional, Union
12
+
13
+ logger = logging.getLogger("orbitalsai.streaming")
14
+
15
+
16
+ class MessageType(Enum):
17
+ """WebSocket message types for the streaming protocol."""
18
+
19
+ # Server → Client
20
+ READY = "ready"
21
+ PARTIAL = "partial"
22
+ FINAL = "final"
23
+ SPEECH_START = "speech_start"
24
+ SPEECH_END = "speech_end"
25
+ LANGUAGE_SET = "language_set"
26
+ SAMPLE_RATE_SET = "sample_rate_set"
27
+ CREDITS_WARNING = "credits_warning"
28
+ CREDITS_CRITICAL = "credits_critical"
29
+ CREDITS_EXHAUSTED = "credits_exhausted"
30
+ FLUSHED = "flushed"
31
+ ERROR = "error"
32
+
33
+ # Client → Server
34
+ CONFIG = "config"
35
+ FLUSH = "flush"
36
+ AUDIO = "audio" # Binary, not JSON
37
+
38
+
39
+ @dataclass
40
+ class Message:
41
+ """
42
+ Represents a parsed WebSocket message.
43
+
44
+ Attributes:
45
+ type: Message type enum
46
+ data: Message payload data
47
+ raw: Original raw message (for debugging)
48
+ """
49
+ type: MessageType
50
+ data: Dict[str, Any] = field(default_factory=dict)
51
+ raw: Optional[str] = None
52
+
53
+ @property
54
+ def text(self) -> Optional[str]:
55
+ """Get transcript text (for partial/final messages)."""
56
+ return self.data.get("text")
57
+
58
+ @property
59
+ def session_id(self) -> Optional[str]:
60
+ """Get session ID (for ready message)."""
61
+ return self.data.get("session_id")
62
+
63
+ @property
64
+ def language(self) -> Optional[str]:
65
+ """Get language (for ready/language_set messages)."""
66
+ return self.data.get("language")
67
+
68
+ @property
69
+ def sample_rate(self) -> Optional[int]:
70
+ """Get sample rate (for sample_rate_set message)."""
71
+ return self.data.get("sample_rate")
72
+
73
+ @property
74
+ def supported_languages(self) -> List[str]:
75
+ """Get supported languages (for ready message)."""
76
+ return self.data.get("supported_languages", [])
77
+
78
+ @property
79
+ def cost(self) -> Optional[float]:
80
+ """Get cost (for final message)."""
81
+ return self.data.get("cost")
82
+
83
+ @property
84
+ def audio_seconds(self) -> Optional[float]:
85
+ """Get audio duration (for final message)."""
86
+ return self.data.get("audio_seconds")
87
+
88
+ @property
89
+ def remaining_percent(self) -> Optional[int]:
90
+ """Get remaining credits percent (for final/credits_* messages)."""
91
+ return self.data.get("remaining_percent")
92
+
93
+ @property
94
+ def capped(self) -> bool:
95
+ """Check if billing was capped (for final message)."""
96
+ return self.data.get("capped", False)
97
+
98
+ @property
99
+ def error_message(self) -> Optional[str]:
100
+ """Get error message (for error message)."""
101
+ return self.data.get("message")
102
+
103
+
104
+ class MessageParser:
105
+ """
106
+ Parser for streaming protocol messages.
107
+
108
+ Handles conversion between raw WebSocket messages and Message objects.
109
+ """
110
+
111
+ @staticmethod
112
+ def parse(raw: str) -> Message:
113
+ """
114
+ Parse a raw JSON message from the server.
115
+
116
+ Args:
117
+ raw: Raw JSON string from WebSocket
118
+
119
+ Returns:
120
+ Parsed Message object
121
+
122
+ Raises:
123
+ ValueError: If message is invalid or has unknown type
124
+ """
125
+ try:
126
+ data = json.loads(raw)
127
+ except json.JSONDecodeError as e:
128
+ raise ValueError(f"Invalid JSON message: {e}")
129
+
130
+ msg_type_str = data.get("type")
131
+ if not msg_type_str:
132
+ raise ValueError("Message missing 'type' field")
133
+
134
+ try:
135
+ msg_type = MessageType(msg_type_str)
136
+ except ValueError:
137
+ logger.warning(f"Unknown message type: {msg_type_str}")
138
+ raise ValueError(f"Unknown message type: {msg_type_str}")
139
+
140
+ return Message(type=msg_type, data=data, raw=raw)
141
+
142
+ @staticmethod
143
+ def encode_config(
144
+ language: Optional[str] = None,
145
+ sample_rate: Optional[int] = None
146
+ ) -> str:
147
+ """
148
+ Encode a config message to send to server.
149
+
150
+ Args:
151
+ language: Optional language to set
152
+ sample_rate: Optional sample rate to set
153
+
154
+ Returns:
155
+ JSON string to send
156
+ """
157
+ msg = {"type": "config"}
158
+ if language is not None:
159
+ msg["language"] = language
160
+ if sample_rate is not None:
161
+ msg["sample_rate"] = sample_rate
162
+ return json.dumps(msg)
163
+
164
+ @staticmethod
165
+ def encode_flush() -> str:
166
+ """
167
+ Encode a flush message to send to server.
168
+
169
+ Returns:
170
+ JSON string to send
171
+ """
172
+ return json.dumps({"type": "flush"})
173
+
174
+
175
+ def create_websocket_url(base_url: str, api_key: str) -> str:
176
+ """
177
+ Create WebSocket URL with authentication.
178
+
179
+ Args:
180
+ base_url: Base URL (e.g., "wss://api.orbitalsai.com")
181
+ api_key: API key or JWT token
182
+
183
+ Returns:
184
+ Full WebSocket URL with token parameter
185
+ """
186
+ # Ensure we have the right protocol
187
+ if base_url.startswith("https://"):
188
+ base_url = "wss://" + base_url[8:]
189
+ elif base_url.startswith("http://"):
190
+ base_url = "ws://" + base_url[7:]
191
+ elif not base_url.startswith(("ws://", "wss://")):
192
+ base_url = "wss://" + base_url
193
+
194
+ # Remove trailing slash
195
+ base_url = base_url.rstrip("/")
196
+
197
+ # Build URL with endpoint and token
198
+ return f"{base_url}/api/v1/streaming/transcribe?token={api_key}"
199
+
200
+
201
+ # Close codes and their meanings
202
+ CLOSE_CODES = {
203
+ 1000: ("Normal closure", False), # No retry needed
204
+ 1001: ("Going away", True), # Server shutdown, retry
205
+ 1006: ("Abnormal closure", True), # Network issue, retry
206
+ 4001: ("Authentication failed", False), # Don't retry
207
+ 4002: ("Service unavailable", True), # Retry with backoff
208
+ 4003: ("Server busy", True), # Retry with longer backoff
209
+ 4004: ("Insufficient balance", False), # Don't retry
210
+ 4005: ("Credits exhausted", False), # Don't retry
211
+ }
212
+
213
+
214
+ def should_retry(close_code: int) -> bool:
215
+ """
216
+ Determine if connection should be retried based on close code.
217
+
218
+ Args:
219
+ close_code: WebSocket close code
220
+
221
+ Returns:
222
+ True if connection should be retried, False otherwise
223
+ """
224
+ if close_code in CLOSE_CODES:
225
+ _, should_retry = CLOSE_CODES[close_code]
226
+ return should_retry
227
+
228
+ # Default: retry for server errors (5xx equivalent)
229
+ return close_code >= 1000 and close_code < 4000
230
+
231
+
232
+ def get_close_reason(close_code: int) -> str:
233
+ """
234
+ Get human-readable reason for close code.
235
+
236
+ Args:
237
+ close_code: WebSocket close code
238
+
239
+ Returns:
240
+ Human-readable reason string
241
+ """
242
+ if close_code in CLOSE_CODES:
243
+ reason, _ = CLOSE_CODES[close_code]
244
+ return reason
245
+ return f"Unknown close code: {close_code}"