olbrain-python-sdk 0.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.
olbrain/__init__.py ADDED
@@ -0,0 +1,58 @@
1
+ """
2
+ Olbrain Python SDK
3
+ Simple client for Olbrain AI agents with real-time message streaming
4
+
5
+ Example:
6
+ >>> from olbrain import AgentClient
7
+ >>>
8
+ >>> client = AgentClient(agent_id="agent-123", api_key="ak_...")
9
+ >>>
10
+ >>> # Create session and send messages
11
+ >>> session_id = client.create_session(title="My Chat")
12
+ >>> response = client.send_and_wait(session_id, "Hello!")
13
+ >>> print(response.text)
14
+ >>>
15
+ >>> # Or with real-time streaming
16
+ >>> def on_message(msg):
17
+ ... print(f"{msg['role']}: {msg['content']}")
18
+ >>> session_id = client.create_session(on_message=on_message, title="Streaming Chat")
19
+ >>> client.send(session_id, "Hello!")
20
+ >>> client.run() # Blocks, receives all messages
21
+ """
22
+
23
+ from .client import AgentClient
24
+ from .session import ChatResponse, SessionInfo, TokenUsage, Message, Session
25
+ from .exceptions import (
26
+ OlbrainError,
27
+ AuthenticationError,
28
+ NetworkError,
29
+ SessionError,
30
+ SessionNotFoundError,
31
+ RateLimitError,
32
+ ValidationError,
33
+ StreamingError
34
+ )
35
+
36
+ __version__ = "0.2.0"
37
+ __author__ = "Olbrain Team"
38
+ __email__ = "support@olbrain.com"
39
+
40
+ __all__ = [
41
+ # Main client
42
+ 'AgentClient',
43
+ # Data classes
44
+ 'ChatResponse',
45
+ 'SessionInfo',
46
+ 'TokenUsage',
47
+ 'Message',
48
+ 'Session',
49
+ # Exceptions
50
+ 'OlbrainError',
51
+ 'AuthenticationError',
52
+ 'NetworkError',
53
+ 'SessionError',
54
+ 'SessionNotFoundError',
55
+ 'RateLimitError',
56
+ 'ValidationError',
57
+ 'StreamingError'
58
+ ]
olbrain/client.py ADDED
@@ -0,0 +1,627 @@
1
+ """
2
+ Olbrain Python SDK - Simple Client
3
+ Clean API for agent interaction with real-time message streaming
4
+ """
5
+
6
+ import requests
7
+ import logging
8
+ import time
9
+ from typing import Callable, Optional, Dict, Any, List
10
+
11
+ from .streaming import MessageStream
12
+ from .session import ChatResponse, SessionInfo, TokenUsage
13
+ from .exceptions import (
14
+ OlbrainError,
15
+ AuthenticationError,
16
+ NetworkError,
17
+ SessionNotFoundError,
18
+ RateLimitError
19
+ )
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class AgentClient:
25
+ """
26
+ Simple client for interacting with Olbrain agents
27
+
28
+ Provides unified message handling - all messages (responses + scheduled)
29
+ come through the same callback stream.
30
+
31
+ Args:
32
+ agent_id: Agent identifier
33
+ api_key: API key (starts with 'ak_')
34
+ agent_url: Optional custom agent URL (auto-constructed if not provided)
35
+
36
+ Example:
37
+ >>> from olbrain import AgentClient
38
+ >>>
39
+ >>> client = AgentClient(agent_id="agent-123", api_key="ak_...")
40
+ >>>
41
+ >>> def on_message(msg):
42
+ ... print(f"{msg['role']}: {msg['content']}")
43
+ >>>
44
+ >>> session = client.create_session(on_message=on_message)
45
+ >>> client.send(session, "Hello!")
46
+ >>> client.run() # Blocks, receives all messages
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ agent_id: str,
52
+ api_key: str,
53
+ agent_url: Optional[str] = None
54
+ ):
55
+ """
56
+ Initialize Olbrain client
57
+
58
+ Args:
59
+ agent_id: Agent identifier
60
+ api_key: API key (must start with 'ak_')
61
+ agent_url: Optional agent URL (auto-constructed if not provided)
62
+
63
+ Raises:
64
+ ValueError: If agent_id or api_key is invalid
65
+ """
66
+ if not agent_id:
67
+ raise ValueError("agent_id is required")
68
+
69
+ if not api_key or not api_key.startswith('ak_'):
70
+ raise ValueError("Invalid API key - must start with 'ak_'")
71
+
72
+ self.agent_id = agent_id
73
+ self.api_key = api_key
74
+
75
+ # Auto-construct agent URL if not provided
76
+ if agent_url:
77
+ self.agent_url = agent_url.rstrip('/')
78
+ else:
79
+ # Use default Cloud Run URL pattern
80
+ self.agent_url = f"https://agent-{agent_id}-768934887465.us-central1.run.app"
81
+
82
+ self._streams = {} # session_id -> MessageStream
83
+ self._running = False
84
+
85
+ logger.info(f"AgentClient initialized for {agent_id}")
86
+
87
+ def create_session(
88
+ self,
89
+ on_message: Callable = None,
90
+ title: str = None,
91
+ user_id: str = None,
92
+ metadata: Dict[str, Any] = None,
93
+ mode: str = "production",
94
+ description: str = None
95
+ ) -> str:
96
+ """
97
+ Create new session and optionally start listening for messages.
98
+
99
+ Args:
100
+ on_message: Optional callback function(message_dict) for real-time messages.
101
+ If provided, starts listening automatically.
102
+ message_dict = {
103
+ 'role': 'user' | 'assistant',
104
+ 'content': str,
105
+ 'timestamp': str,
106
+ 'token_usage': {...}
107
+ }
108
+ title: Optional title for the session
109
+ user_id: Optional user identifier for tracking
110
+ metadata: Optional metadata dict (can include 'session_description')
111
+ mode: Session mode - 'development', 'testing', or 'production' (default)
112
+ description: Optional session description
113
+
114
+ Returns:
115
+ session_id (string)
116
+
117
+ Raises:
118
+ OlbrainError: If session creation fails
119
+ AuthenticationError: If API key is invalid
120
+
121
+ Example:
122
+ >>> # Create session with streaming
123
+ >>> def handle_msg(msg):
124
+ ... print(msg['content'])
125
+ >>> session = client.create_session(on_message=handle_msg, title="My Chat")
126
+
127
+ >>> # Create session without streaming (for sync usage)
128
+ >>> session = client.create_session(title="Support Chat", user_id="user-123")
129
+ """
130
+ if mode not in ['development', 'testing', 'production']:
131
+ raise ValueError("mode must be 'development', 'testing', or 'production'")
132
+
133
+ try:
134
+ # Build request payload
135
+ request_metadata = metadata.copy() if metadata else {}
136
+ if description:
137
+ request_metadata['session_description'] = description
138
+
139
+ payload = {
140
+ 'message': title or "New Session",
141
+ 'response_mode': 'sync',
142
+ 'mode': mode
143
+ }
144
+ if user_id:
145
+ payload['user_id'] = user_id
146
+ if request_metadata:
147
+ payload['metadata'] = request_metadata
148
+
149
+ # Create session via webhook endpoint
150
+ response = requests.post(
151
+ f"{self.agent_url}/api/agent/webhook",
152
+ headers={
153
+ 'Authorization': f'Bearer {self.api_key}',
154
+ 'Content-Type': 'application/json'
155
+ },
156
+ json=payload,
157
+ timeout=30
158
+ )
159
+
160
+ self._handle_response_errors(response, "create session")
161
+
162
+ data = response.json()
163
+ session_id = data.get('session_id')
164
+
165
+ if not session_id:
166
+ raise OlbrainError("No session_id in response")
167
+
168
+ logger.info(f"Created session {session_id}")
169
+
170
+ # Start listening for messages if callback provided
171
+ if on_message:
172
+ self.listen(session_id, on_message)
173
+
174
+ return session_id
175
+
176
+ except requests.exceptions.RequestException as e:
177
+ raise NetworkError(f"Network error creating session: {e}")
178
+
179
+ def send(
180
+ self,
181
+ session_id: str,
182
+ message: str,
183
+ user_id: str = None,
184
+ metadata: Dict[str, Any] = None,
185
+ model: str = None,
186
+ mode: str = "production"
187
+ ) -> Dict[str, Any]:
188
+ """
189
+ Send message to agent. Response comes via callback if listening,
190
+ or returned directly in sync mode.
191
+
192
+ Args:
193
+ session_id: Session identifier
194
+ message: Message text to send
195
+ user_id: Optional user identifier
196
+ metadata: Optional message metadata
197
+ model: Optional model override (e.g., 'gpt-4', 'claude-3-opus')
198
+ mode: Session mode - 'development', 'testing', or 'production'
199
+
200
+ Returns:
201
+ Response dict with success, response, token_usage, etc.
202
+
203
+ Raises:
204
+ ValueError: If message is empty
205
+ OlbrainError: If send fails
206
+ SessionNotFoundError: If session doesn't exist
207
+
208
+ Example:
209
+ >>> response = client.send(session_id, "What's 2+2?")
210
+ >>> print(response['response'])
211
+ """
212
+ if not message or not message.strip():
213
+ raise ValueError("Message cannot be empty")
214
+
215
+ try:
216
+ payload = {
217
+ 'session_id': session_id,
218
+ 'message': message.strip(),
219
+ 'response_mode': 'sync',
220
+ 'mode': mode
221
+ }
222
+ if user_id:
223
+ payload['user_id'] = user_id
224
+ if metadata:
225
+ payload['metadata'] = metadata
226
+ if model:
227
+ payload['model'] = model
228
+
229
+ response = requests.post(
230
+ f"{self.agent_url}/api/agent/webhook",
231
+ headers={
232
+ 'Authorization': f'Bearer {self.api_key}',
233
+ 'Content-Type': 'application/json'
234
+ },
235
+ json=payload,
236
+ timeout=120
237
+ )
238
+
239
+ self._handle_response_errors(response, "send message")
240
+
241
+ data = response.json()
242
+ logger.debug(f"Sent message to session {session_id}")
243
+ return data
244
+
245
+ except requests.exceptions.RequestException as e:
246
+ raise NetworkError(f"Network error sending message: {e}")
247
+
248
+ def send_and_wait(
249
+ self,
250
+ session_id: str,
251
+ message: str,
252
+ user_id: str = None,
253
+ metadata: Dict[str, Any] = None,
254
+ model: str = None,
255
+ timeout: int = 120
256
+ ) -> ChatResponse:
257
+ """
258
+ Send message and wait for response (synchronous).
259
+
260
+ This method sends a message and returns a ChatResponse object
261
+ with the agent's reply. Use this for simple request-response patterns.
262
+
263
+ Args:
264
+ session_id: Session identifier
265
+ message: Message text to send
266
+ user_id: Optional user identifier
267
+ metadata: Optional message metadata
268
+ model: Optional model override
269
+ timeout: Request timeout in seconds (default: 120)
270
+
271
+ Returns:
272
+ ChatResponse with text, token_usage, model_used, etc.
273
+
274
+ Raises:
275
+ ValueError: If message is empty
276
+ OlbrainError: If send fails
277
+ SessionNotFoundError: If session doesn't exist
278
+
279
+ Example:
280
+ >>> response = client.send_and_wait(session_id, "Explain quantum computing")
281
+ >>> print(response.text)
282
+ >>> print(f"Tokens used: {response.token_usage.total_tokens}")
283
+ """
284
+ if not message or not message.strip():
285
+ raise ValueError("Message cannot be empty")
286
+
287
+ try:
288
+ payload = {
289
+ 'message': message.strip()
290
+ }
291
+ if user_id:
292
+ payload['user_id'] = user_id
293
+ if metadata:
294
+ payload['metadata'] = metadata
295
+
296
+ response = requests.post(
297
+ f"{self.agent_url}/sessions/{session_id}/messages",
298
+ headers={
299
+ 'Authorization': f'Bearer {self.api_key}',
300
+ 'Content-Type': 'application/json'
301
+ },
302
+ json=payload,
303
+ timeout=timeout
304
+ )
305
+
306
+ self._handle_response_errors(response, "send message")
307
+
308
+ data = response.json()
309
+ logger.debug(f"Received response for session {session_id}")
310
+ return ChatResponse.from_dict(data, session_id)
311
+
312
+ except requests.exceptions.RequestException as e:
313
+ raise NetworkError(f"Network error sending message: {e}")
314
+
315
+ def listen(self, session_id: str, on_message: Callable):
316
+ """
317
+ Start listening for messages on existing session
318
+
319
+ Args:
320
+ session_id: Session to listen to
321
+ on_message: Callback function(message_dict) for messages
322
+
323
+ Example:
324
+ >>> def handle_msg(msg):
325
+ ... print(msg['content'])
326
+ >>>
327
+ >>> client.listen("existing-session-id", on_message=handle_msg)
328
+ """
329
+ if session_id in self._streams:
330
+ logger.warning(f"Already listening to session {session_id}")
331
+ return
332
+
333
+ stream = MessageStream(
334
+ agent_url=self.agent_url,
335
+ api_key=self.api_key,
336
+ session_id=session_id,
337
+ on_message=on_message
338
+ )
339
+ stream.start()
340
+ self._streams[session_id] = stream
341
+
342
+ logger.info(f"Started listening to session {session_id}")
343
+
344
+ def run(self):
345
+ """
346
+ Block forever and process message callbacks
347
+
348
+ Call this to keep your program running and receive messages.
349
+ Press Ctrl+C to exit gracefully.
350
+
351
+ Example:
352
+ >>> client.run() # Blocks until Ctrl+C
353
+ """
354
+ self._running = True
355
+ logger.info("Client running - press Ctrl+C to exit")
356
+
357
+ try:
358
+ while self._running:
359
+ time.sleep(1)
360
+ except KeyboardInterrupt:
361
+ logger.info("Received interrupt signal, shutting down...")
362
+ self.close()
363
+
364
+ def close(self):
365
+ """
366
+ Stop all streams and cleanup resources
367
+
368
+ Automatically called on Ctrl+C or when using context manager.
369
+ """
370
+ logger.info("Closing client...")
371
+ self._running = False
372
+
373
+ # Stop all streams
374
+ for session_id, stream in list(self._streams.items()):
375
+ stream.stop()
376
+
377
+ self._streams.clear()
378
+ logger.info("Client closed")
379
+
380
+ def __enter__(self):
381
+ """Context manager entry"""
382
+ return self
383
+
384
+ def __exit__(self, exc_type, exc_val, exc_tb):
385
+ """Context manager exit"""
386
+ self.close()
387
+
388
+ def __repr__(self) -> str:
389
+ return f"AgentClient(agent_id={self.agent_id})"
390
+
391
+ # -------------------------------------------------------------------------
392
+ # Session Management Methods
393
+ # -------------------------------------------------------------------------
394
+
395
+ def get_session(self, session_id: str) -> SessionInfo:
396
+ """
397
+ Get session details.
398
+
399
+ Args:
400
+ session_id: Session identifier
401
+
402
+ Returns:
403
+ SessionInfo with session details
404
+
405
+ Raises:
406
+ SessionNotFoundError: If session doesn't exist
407
+ OlbrainError: If request fails
408
+
409
+ Example:
410
+ >>> info = client.get_session(session_id)
411
+ >>> print(f"Title: {info.title}, Messages: {info.message_count}")
412
+ """
413
+ try:
414
+ response = requests.get(
415
+ f"{self.agent_url}/sessions/{session_id}",
416
+ headers={'Authorization': f'Bearer {self.api_key}'},
417
+ timeout=30
418
+ )
419
+
420
+ self._handle_response_errors(response, "get session")
421
+
422
+ data = response.json()
423
+ return SessionInfo.from_dict(data)
424
+
425
+ except requests.exceptions.RequestException as e:
426
+ raise NetworkError(f"Network error getting session: {e}")
427
+
428
+ def update_session(
429
+ self,
430
+ session_id: str,
431
+ title: str = None,
432
+ metadata: Dict[str, Any] = None,
433
+ status: str = None
434
+ ) -> SessionInfo:
435
+ """
436
+ Update session details.
437
+
438
+ Args:
439
+ session_id: Session identifier
440
+ title: New session title (optional)
441
+ metadata: New metadata dict (optional)
442
+ status: New status - 'active' or 'archived' (optional)
443
+
444
+ Returns:
445
+ Updated SessionInfo
446
+
447
+ Raises:
448
+ SessionNotFoundError: If session doesn't exist
449
+ OlbrainError: If update fails
450
+
451
+ Example:
452
+ >>> updated = client.update_session(session_id, title="Renamed Chat")
453
+ """
454
+ try:
455
+ payload = {}
456
+ if title is not None:
457
+ payload['title'] = title
458
+ if metadata is not None:
459
+ payload['metadata'] = metadata
460
+ if status is not None:
461
+ if status not in ['active', 'archived']:
462
+ raise ValueError("status must be 'active' or 'archived'")
463
+ payload['status'] = status
464
+
465
+ response = requests.put(
466
+ f"{self.agent_url}/sessions/{session_id}",
467
+ headers={
468
+ 'Authorization': f'Bearer {self.api_key}',
469
+ 'Content-Type': 'application/json'
470
+ },
471
+ json=payload,
472
+ timeout=30
473
+ )
474
+
475
+ self._handle_response_errors(response, "update session")
476
+
477
+ data = response.json()
478
+ return SessionInfo.from_dict(data)
479
+
480
+ except requests.exceptions.RequestException as e:
481
+ raise NetworkError(f"Network error updating session: {e}")
482
+
483
+ def delete_session(self, session_id: str) -> Dict[str, Any]:
484
+ """
485
+ Archive/delete a session.
486
+
487
+ Args:
488
+ session_id: Session identifier
489
+
490
+ Returns:
491
+ Dict with success status and archived_at timestamp
492
+
493
+ Raises:
494
+ SessionNotFoundError: If session doesn't exist
495
+ OlbrainError: If delete fails
496
+
497
+ Example:
498
+ >>> result = client.delete_session(session_id)
499
+ >>> print(f"Archived at: {result['archived_at']}")
500
+ """
501
+ try:
502
+ # Stop listening if we have an active stream
503
+ if session_id in self._streams:
504
+ self._streams[session_id].stop()
505
+ del self._streams[session_id]
506
+
507
+ response = requests.delete(
508
+ f"{self.agent_url}/sessions/{session_id}",
509
+ headers={'Authorization': f'Bearer {self.api_key}'},
510
+ timeout=30
511
+ )
512
+
513
+ self._handle_response_errors(response, "delete session")
514
+
515
+ return response.json()
516
+
517
+ except requests.exceptions.RequestException as e:
518
+ raise NetworkError(f"Network error deleting session: {e}")
519
+
520
+ def get_session_stats(self, session_id: str) -> Dict[str, Any]:
521
+ """
522
+ Get session statistics (token usage, message counts, etc.).
523
+
524
+ Args:
525
+ session_id: Session identifier
526
+
527
+ Returns:
528
+ Dict with session statistics
529
+
530
+ Raises:
531
+ SessionNotFoundError: If session doesn't exist
532
+ OlbrainError: If request fails
533
+
534
+ Example:
535
+ >>> stats = client.get_session_stats(session_id)
536
+ >>> print(f"Total tokens: {stats['stats'].get('total_tokens', 0)}")
537
+ """
538
+ try:
539
+ response = requests.get(
540
+ f"{self.agent_url}/sessions/{session_id}/stats",
541
+ headers={'Authorization': f'Bearer {self.api_key}'},
542
+ timeout=30
543
+ )
544
+
545
+ self._handle_response_errors(response, "get session stats")
546
+
547
+ return response.json()
548
+
549
+ except requests.exceptions.RequestException as e:
550
+ raise NetworkError(f"Network error getting session stats: {e}")
551
+
552
+ def get_messages(
553
+ self,
554
+ session_id: str,
555
+ limit: int = 20,
556
+ offset: int = 0
557
+ ) -> Dict[str, Any]:
558
+ """
559
+ Get message history for a session.
560
+
561
+ Args:
562
+ session_id: Session identifier
563
+ limit: Maximum messages to return (default: 20)
564
+ offset: Number of messages to skip (default: 0)
565
+
566
+ Returns:
567
+ Dict with messages list and pagination info
568
+
569
+ Raises:
570
+ SessionNotFoundError: If session doesn't exist
571
+ OlbrainError: If request fails
572
+
573
+ Example:
574
+ >>> result = client.get_messages(session_id, limit=50)
575
+ >>> for msg in result['messages']:
576
+ ... print(f"{msg['role']}: {msg['content']}")
577
+ """
578
+ try:
579
+ response = requests.get(
580
+ f"{self.agent_url}/sessions/{session_id}/messages",
581
+ headers={'Authorization': f'Bearer {self.api_key}'},
582
+ params={'limit': limit, 'offset': offset},
583
+ timeout=30
584
+ )
585
+
586
+ self._handle_response_errors(response, "get messages")
587
+
588
+ return response.json()
589
+
590
+ except requests.exceptions.RequestException as e:
591
+ raise NetworkError(f"Network error getting messages: {e}")
592
+
593
+ # -------------------------------------------------------------------------
594
+ # Helper Methods
595
+ # -------------------------------------------------------------------------
596
+
597
+ def _handle_response_errors(self, response: requests.Response, operation: str):
598
+ """Handle HTTP response errors and raise appropriate exceptions."""
599
+ if response.status_code == 200:
600
+ return
601
+
602
+ if response.status_code == 401:
603
+ raise AuthenticationError("Invalid API key")
604
+
605
+ if response.status_code == 404:
606
+ raise SessionNotFoundError(f"Session not found")
607
+
608
+ if response.status_code == 429:
609
+ try:
610
+ data = response.json()
611
+ retry_after = data.get('detail', {}).get('retry_after')
612
+ raise RateLimitError(
613
+ f"Rate limit exceeded",
614
+ retry_after=retry_after
615
+ )
616
+ except (ValueError, KeyError):
617
+ raise RateLimitError("Rate limit exceeded")
618
+
619
+ # Generic error
620
+ try:
621
+ error_detail = response.json().get('detail', response.text)
622
+ except ValueError:
623
+ error_detail = response.text
624
+
625
+ raise OlbrainError(
626
+ f"Failed to {operation}: {response.status_code} - {error_detail}"
627
+ )