kiwi-code 0.0.4__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.
kiwi_tui/client.py ADDED
@@ -0,0 +1,539 @@
1
+ """Autobots client wrapper for API interactions."""
2
+
3
+ from datetime import datetime, timedelta
4
+ from typing import Optional, Callable
5
+ import time
6
+ import json
7
+ import codecs
8
+ import httpx
9
+ from loguru import logger
10
+ from autobots_client import Client, AuthenticatedClient
11
+ from autobots_client.api.hello import hello_v1_hello_get
12
+ from autobots_client.api.auth import (
13
+ return_user_and_session_v1_auth_post,
14
+ refresh_password_email_v1_auth_session_refresh_post,
15
+ )
16
+ from autobots_client.api.actions import async_run_action_v1_actions_id_async_run_post
17
+ from autobots_client.api.action_results import get_action_result_v1_action_results_id_get
18
+ from autobots_client.models.body_return_user_and_session_v1_auth_post import (
19
+ BodyReturnUserAndSessionV1AuthPost,
20
+ )
21
+ from autobots_client.models.body_async_run_action_v1_actions_id_async_run_post import (
22
+ BodyAsyncRunActionV1ActionsIdAsyncRunPost,
23
+ )
24
+ from autobots_client.models.body_async_run_action_v1_actions_id_async_run_post_input import (
25
+ BodyAsyncRunActionV1ActionsIdAsyncRunPostInput,
26
+ )
27
+
28
+ from .models import AuthTokens, LoginCredentials
29
+
30
+
31
+ class AutobotsClientWrapper:
32
+ """Wrapper around autobots_client for API calls."""
33
+
34
+ def __init__(
35
+ self,
36
+ base_url: str,
37
+ access_token: Optional[str] = None,
38
+ api_key: Optional[str] = None,
39
+ ):
40
+ """Initialize autobots client wrapper.
41
+
42
+ Args:
43
+ base_url: Base URL of the autobots backend
44
+ access_token: Optional access token for authentication
45
+ api_key: Optional API key for authentication (legacy)
46
+ """
47
+ self.base_url = base_url
48
+ self.access_token = access_token
49
+ self.api_key = api_key
50
+
51
+ # Use access_token first, then api_key, then unauthenticated
52
+ token = access_token or api_key
53
+
54
+ if token:
55
+ self.client = AuthenticatedClient(
56
+ base_url=base_url,
57
+ token=token,
58
+ raise_on_unexpected_status=False,
59
+ )
60
+ logger.info(f"Initialized authenticated client for {base_url}")
61
+ else:
62
+ self.client = Client(
63
+ base_url=base_url,
64
+ raise_on_unexpected_status=False,
65
+ )
66
+ logger.info(f"Initialized unauthenticated client for {base_url}")
67
+
68
+ def update_token(self, access_token: str) -> None:
69
+ """Update the client with a new access token.
70
+
71
+ Args:
72
+ access_token: New access token
73
+ """
74
+ self.access_token = access_token
75
+ self.client = AuthenticatedClient(
76
+ base_url=self.base_url,
77
+ token=access_token,
78
+ raise_on_unexpected_status=False,
79
+ )
80
+ logger.info("Client updated with new access token")
81
+
82
+ def hello(self) -> tuple[bool, str]:
83
+ """Call the hello API endpoint.
84
+
85
+ Returns:
86
+ Tuple of (success, message)
87
+ """
88
+ try:
89
+ logger.debug("Calling hello API")
90
+ response = hello_v1_hello_get.sync_detailed(client=self.client)
91
+
92
+ if response.status_code == 200:
93
+ # Get response content as text
94
+ message = response.content.decode('utf-8') if response.content else "Hello from Autobots!"
95
+ logger.info(f"Hello API success: {message}")
96
+ return True, message
97
+ else:
98
+ error_msg = f"Hello API returned status {response.status_code}"
99
+ logger.warning(error_msg)
100
+ return False, error_msg
101
+
102
+ except Exception as e:
103
+ error_msg = f"Failed to call hello API: {str(e)}"
104
+ logger.error(error_msg)
105
+ return False, error_msg
106
+
107
+ def login(self, credentials: LoginCredentials) -> tuple[bool, Optional[AuthTokens], str]:
108
+ """Login with username and password.
109
+
110
+ Args:
111
+ credentials: Login credentials
112
+
113
+ Returns:
114
+ Tuple of (success, auth_tokens, message)
115
+ """
116
+ try:
117
+ logger.info(f"Attempting login for user: {credentials.username}")
118
+
119
+ body = BodyReturnUserAndSessionV1AuthPost(
120
+ username=credentials.username,
121
+ password=credentials.password,
122
+ )
123
+
124
+ response = return_user_and_session_v1_auth_post.sync_detailed(
125
+ client=self.client,
126
+ body=body,
127
+ )
128
+
129
+ if response.status_code == 200 and response.parsed:
130
+ auth_response = response.parsed
131
+
132
+ if auth_response.session:
133
+ session = auth_response.session
134
+
135
+ # Calculate expiry time
136
+ expires_at = None
137
+ if hasattr(session, 'expires_in') and session.expires_in:
138
+ expires_at = datetime.now() + timedelta(seconds=session.expires_in)
139
+
140
+ tokens = AuthTokens(
141
+ access_token=session.access_token,
142
+ refresh_token=session.refresh_token,
143
+ token_type=session.token_type,
144
+ expires_at=expires_at,
145
+ )
146
+
147
+ # Update client with new token
148
+ self.update_token(tokens.access_token)
149
+
150
+ logger.info(f"Login successful for user: {credentials.username}")
151
+ return True, tokens, "Login successful"
152
+ else:
153
+ error_msg = "No session in response"
154
+ logger.error(error_msg)
155
+ return False, None, error_msg
156
+ else:
157
+ error_msg = f"Login failed with status {response.status_code}"
158
+ if response.content:
159
+ error_msg += f": {response.content.decode('utf-8')}"
160
+ logger.error(error_msg)
161
+ return False, None, error_msg
162
+
163
+ except Exception as e:
164
+ error_msg = f"Login error: {str(e)}"
165
+ logger.error(error_msg)
166
+ return False, None, error_msg
167
+
168
+ def refresh_token(self, refresh_token: str) -> tuple[bool, Optional[AuthTokens], str]:
169
+ """Refresh access token using refresh token.
170
+
171
+ Args:
172
+ refresh_token: Refresh token
173
+
174
+ Returns:
175
+ Tuple of (success, new_auth_tokens, message)
176
+ """
177
+ try:
178
+ logger.info("Attempting to refresh access token")
179
+
180
+ # Need authenticated client for refresh
181
+ if not isinstance(self.client, AuthenticatedClient):
182
+ temp_client = AuthenticatedClient(
183
+ base_url=self.base_url,
184
+ token=refresh_token,
185
+ raise_on_unexpected_status=False,
186
+ )
187
+ else:
188
+ temp_client = self.client
189
+
190
+ response = refresh_password_email_v1_auth_session_refresh_post.sync_detailed(
191
+ client=temp_client,
192
+ refresh_token=refresh_token,
193
+ )
194
+
195
+ if response.status_code == 200 and response.parsed:
196
+ auth_response = response.parsed
197
+
198
+ if auth_response.session:
199
+ session = auth_response.session
200
+
201
+ # Calculate expiry time
202
+ expires_at = None
203
+ if hasattr(session, 'expires_in') and session.expires_in:
204
+ expires_at = datetime.now() + timedelta(seconds=session.expires_in)
205
+
206
+ tokens = AuthTokens(
207
+ access_token=session.access_token,
208
+ refresh_token=session.refresh_token,
209
+ token_type=session.token_type,
210
+ expires_at=expires_at,
211
+ )
212
+
213
+ # Update client with new token
214
+ self.update_token(tokens.access_token)
215
+
216
+ logger.info("Token refresh successful")
217
+ return True, tokens, "Token refreshed successfully"
218
+ else:
219
+ error_msg = "No session in refresh response"
220
+ logger.error(error_msg)
221
+ return False, None, error_msg
222
+ else:
223
+ error_msg = f"Token refresh failed with status {response.status_code}"
224
+ logger.error(error_msg)
225
+ return False, None, error_msg
226
+
227
+ except Exception as e:
228
+ error_msg = f"Token refresh error: {str(e)}"
229
+ logger.error(error_msg)
230
+ return False, None, error_msg
231
+
232
+ def run_action_async(self, action_id: str, user_input: str, action_result_id: Optional[str] = None) -> tuple[bool, Optional[str], str]:
233
+ """Run an action asynchronously and return the run ID.
234
+
235
+ Args:
236
+ action_id: ID of the action to run
237
+ user_input: User input text for the action
238
+ action_result_id: Optional ID to continue an existing conversation
239
+
240
+ Returns:
241
+ Tuple of (success, run_id, message)
242
+ """
243
+ try:
244
+ if action_result_id:
245
+ logger.info(f"Running action {action_id} with input: {user_input}, continuing run_id: {action_result_id}")
246
+ else:
247
+ logger.info(f"Running action {action_id} with input: {user_input}")
248
+
249
+ if not isinstance(self.client, AuthenticatedClient):
250
+ return False, None, "Authentication required"
251
+
252
+ # Create input body - DataBlock structure
253
+ input_data = BodyAsyncRunActionV1ActionsIdAsyncRunPostInput()
254
+ input_data.additional_properties = {
255
+ "text": user_input,
256
+ "urls": [],
257
+ "metadata": {
258
+ "process_url_in_text": False,
259
+ "process_attachment_url": True,
260
+ "attachment_requested": 0
261
+ }
262
+ }
263
+
264
+ body = BodyAsyncRunActionV1ActionsIdAsyncRunPost(input_=input_data)
265
+
266
+ # Import UNSET for optional parameter
267
+ from autobots_client.types import UNSET
268
+
269
+ response = async_run_action_v1_actions_id_async_run_post.sync_detailed(
270
+ id=action_id,
271
+ client=self.client,
272
+ body=body,
273
+ action_result_id=action_result_id if action_result_id else UNSET,
274
+ )
275
+
276
+ if response.status_code == 200 and response.parsed:
277
+ action_result = response.parsed
278
+
279
+ # ActionResultDoc has field_id as the run ID
280
+ run_id = getattr(action_result, 'field_id', None)
281
+
282
+ # Fallback to additional_properties if needed
283
+ if not run_id and hasattr(action_result, 'additional_properties'):
284
+ run_id = action_result.additional_properties.get('_id')
285
+
286
+ if run_id:
287
+ logger.info(f"Action started with run ID: {run_id}")
288
+ return True, run_id, f"Action started with ID: {run_id}"
289
+ else:
290
+ error_msg = "No run ID found in response"
291
+ logger.error(error_msg)
292
+ return False, None, error_msg
293
+ else:
294
+ error_msg = f"Failed to start action: status {response.status_code}"
295
+ logger.error(error_msg)
296
+ return False, None, error_msg
297
+
298
+ except Exception as e:
299
+ error_msg = f"Error running action: {str(e)}"
300
+ logger.error(error_msg)
301
+ return False, None, error_msg
302
+
303
+ def get_action_result(self, run_id: str) -> tuple[bool, Optional[dict], str]:
304
+ """Get the result of an action run.
305
+
306
+ Args:
307
+ run_id: ID of the action run (action_results id)
308
+
309
+ Returns:
310
+ Tuple of (success, result_data, message)
311
+ """
312
+ try:
313
+ if not isinstance(self.client, AuthenticatedClient):
314
+ return False, None, "Authentication required"
315
+
316
+ response = get_action_result_v1_action_results_id_get.sync_detailed(
317
+ id=run_id,
318
+ client=self.client,
319
+ )
320
+
321
+ if response.status_code == 200 and response.parsed:
322
+ result = response.parsed
323
+
324
+ # Convert the parsed result to dict preserving full structure
325
+ # The result is ActionResultDoc which has:
326
+ # - status: EventResultStatus
327
+ # - result: ActionDoc (which contains results array)
328
+ # - error_message: DataBlock
329
+
330
+ result_dict = {}
331
+
332
+ # Extract status
333
+ if hasattr(result, 'status'):
334
+ status_obj = result.status
335
+ if hasattr(status_obj, 'value'):
336
+ result_dict["status"] = status_obj.value
337
+ else:
338
+ result_dict["status"] = str(status_obj)
339
+
340
+ # Extract the full ActionDoc with results array
341
+ if hasattr(result, 'result'):
342
+ action_doc = result.result
343
+ # Convert ActionDoc to dict
344
+ if hasattr(action_doc, 'to_dict'):
345
+ result_dict["result"] = action_doc.to_dict()
346
+ elif hasattr(action_doc, 'additional_properties'):
347
+ result_dict["result"] = action_doc.additional_properties
348
+ else:
349
+ # Try to extract key fields
350
+ action_dict = {}
351
+ if hasattr(action_doc, 'results'):
352
+ # This is the results array we need
353
+ results = action_doc.results
354
+ if isinstance(results, list):
355
+ action_dict["results"] = [
356
+ r.to_dict() if hasattr(r, 'to_dict') else r
357
+ for r in results
358
+ ]
359
+ else:
360
+ action_dict["results"] = results
361
+ result_dict["result"] = action_dict
362
+
363
+ # Extract error message
364
+ if hasattr(result, 'error_message'):
365
+ error_obj = result.error_message
366
+ if error_obj:
367
+ if hasattr(error_obj, 'to_dict'):
368
+ result_dict["error"] = error_obj.to_dict()
369
+ elif hasattr(error_obj, 'text'):
370
+ result_dict["error"] = error_obj.text
371
+ else:
372
+ result_dict["error"] = str(error_obj)
373
+
374
+ result_dict["id"] = run_id
375
+
376
+ logger.info(f"Parsed result: status={result_dict.get('status')}, has_result={('result' in result_dict)}")
377
+ logger.info(f"Result structure keys: {result_dict.keys()}")
378
+ if "result" in result_dict and isinstance(result_dict["result"], dict):
379
+ logger.info(f"Result.result keys: {result_dict['result'].keys()}")
380
+
381
+ return True, result_dict, "Result retrieved"
382
+ else:
383
+ error_msg = f"Failed to get result: status {response.status_code}"
384
+ logger.error(error_msg)
385
+ return False, None, error_msg
386
+
387
+ except Exception as e:
388
+ error_msg = f"Error getting result: {str(e)}"
389
+ logger.error(error_msg)
390
+ return False, None, error_msg
391
+
392
+ def poll_action_result(self, run_id: str, max_attempts: int = 60, interval: float = 2.0) -> tuple[bool, Optional[dict], str]:
393
+ """Poll for action result until completion or timeout.
394
+
395
+ Args:
396
+ run_id: ID of the action run
397
+ max_attempts: Maximum number of polling attempts (default 60 = 2 minutes)
398
+ interval: Seconds between polls
399
+
400
+ Returns:
401
+ Tuple of (success, result_data, message)
402
+ """
403
+ logger.info(f"Polling for result of run {run_id}")
404
+
405
+ for attempt in range(max_attempts):
406
+ success, result, message = self.get_action_result(run_id)
407
+
408
+ if not success:
409
+ return False, None, message
410
+
411
+ status = result.get("status", "").lower() if result else ""
412
+
413
+ # Check for completion - status can be "completed", "success", or "finished"
414
+ if status in ["completed", "success", "finished"]:
415
+ logger.info(f"Action completed after {attempt + 1} attempts")
416
+ return True, result, "Action completed successfully"
417
+
418
+ # Check for failure
419
+ elif status in ["failed", "error"]:
420
+ error = result.get("error", "Unknown error") if result else "Unknown error"
421
+ logger.error(f"Action failed: {error}")
422
+ return False, result, f"Action failed: {error}"
423
+
424
+ # Still processing
425
+ elif status in ["processing", "running", "pending"]:
426
+ logger.debug(f"Action still {status}, attempt {attempt + 1}/{max_attempts}")
427
+ if attempt < max_attempts - 1:
428
+ time.sleep(interval)
429
+
430
+ # Unknown status
431
+ else:
432
+ logger.warning(f"Unknown status '{status}', continuing to poll...")
433
+ if attempt < max_attempts - 1:
434
+ time.sleep(interval)
435
+
436
+ return False, result, f"Polling timeout after {max_attempts * interval}s - action may still be running"
437
+
438
+ async def stream_action_result(self, run_id: str, on_message: Callable[[dict], None]) -> None:
439
+ """Stream action result updates via Server-Sent Events.
440
+
441
+ Args:
442
+ run_id: ID of the action run
443
+ on_message: Callback function to handle incoming messages
444
+ """
445
+ sse_endpoint = f"{self.base_url}/v1/server_sent_events/stream/{run_id}"
446
+ logger.info(f"Connecting to SSE: {sse_endpoint}")
447
+
448
+ try:
449
+ # Create headers
450
+ headers = {
451
+ "Accept": "text/event-stream",
452
+ "Cache-Control": "no-cache",
453
+ "Connection": "keep-alive",
454
+ }
455
+ if self.access_token:
456
+ headers["Authorization"] = f"Bearer {self.access_token}"
457
+
458
+ # Use httpx to stream SSE with longer timeout
459
+ async with httpx.AsyncClient(timeout=None) as client:
460
+ async with client.stream("GET", sse_endpoint, headers=headers) as response:
461
+ logger.info(f"SSE connected, status: {response.status_code}")
462
+
463
+ if response.status_code != 200:
464
+ logger.error(f"SSE connection failed with status {response.status_code}")
465
+ return
466
+
467
+ # Use incremental UTF-8 decoder to handle multi-byte characters across chunk boundaries
468
+ decoder = codecs.getincrementaldecoder("utf-8")()
469
+ buffer = ""
470
+
471
+ # Process SSE stream byte by byte with incremental decoder
472
+ async for chunk in response.aiter_bytes():
473
+ # Decode chunk with incremental decoder (handles partial multi-byte chars)
474
+ text_chunk = decoder.decode(chunk, final=False)
475
+ buffer += text_chunk
476
+
477
+ # Process complete lines from buffer
478
+ while "\n" in buffer:
479
+ line, buffer = buffer.split("\n", 1)
480
+ line = line.strip()
481
+
482
+ # Skip empty lines
483
+ if not line:
484
+ continue
485
+
486
+ # Skip keep-alive comments (`:keep-alive`)
487
+ if line.startswith(":"):
488
+ logger.debug("Received SSE keep-alive")
489
+ continue
490
+
491
+ # Parse SSE data format: "data: {...}" or "data: plain text"
492
+ if line.startswith("data: "):
493
+ data_str = line[6:].strip()
494
+
495
+ # Try to parse as JSON first
496
+ try:
497
+ data = json.loads(data_str)
498
+ logger.info(f"SSE JSON received, keys: {data.keys() if isinstance(data, dict) else type(data)}")
499
+
500
+ # Call the callback with parsed data
501
+ on_message(data)
502
+
503
+ # Check if action is complete
504
+ if isinstance(data, dict):
505
+ status = data.get("status", "").lower()
506
+ if status in ["completed", "success", "finished", "failed", "error"]:
507
+ logger.info(f"Action completed with status: {status}")
508
+ return
509
+
510
+ except json.JSONDecodeError:
511
+ # Not JSON, treat as plain text status message
512
+ logger.info(f"SSE text message: {data_str}")
513
+
514
+ # Mark as status so dashboard can distinguish from real output
515
+ text_data = {
516
+ "type": "status",
517
+ "text": data_str,
518
+ }
519
+ on_message(text_data)
520
+
521
+ except Exception as e:
522
+ logger.error(f"Error processing SSE data: {e}")
523
+
524
+ # Flush any remaining bytes in decoder
525
+ remaining = decoder.decode(b"", final=True)
526
+ if remaining:
527
+ buffer += remaining
528
+
529
+ except Exception as e:
530
+ logger.error(f"SSE error: {e}")
531
+ raise
532
+
533
+ def close(self) -> None:
534
+ """Close the client connection."""
535
+ try:
536
+ self.client.__exit__(None, None, None)
537
+ logger.info("Client connection closed")
538
+ except Exception as e:
539
+ logger.warning(f"Error closing client: {e}")