glaip-sdk 0.0.6a0__py3-none-any.whl → 0.0.7__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.
@@ -42,6 +42,9 @@ from glaip_sdk.utils.validation import validate_agent_instruction
42
42
  # API endpoints
43
43
  AGENTS_ENDPOINT = "/agents/"
44
44
 
45
+ # SSE content type
46
+ SSE_CONTENT_TYPE = "text/event-stream"
47
+
45
48
  # Set up module-level logger
46
49
  logger = logging.getLogger("glaip_sdk.agents")
47
50
 
@@ -519,7 +522,7 @@ class AgentClient(BaseClient):
519
522
  Returns:
520
523
  Tuple of (payload, data_payload, files_payload, headers, multipart_data)
521
524
  """
522
- headers = {"Accept": "text/event-stream"}
525
+ headers = {"Accept": SSE_CONTENT_TYPE}
523
526
 
524
527
  if files:
525
528
  # Handle multipart data for file uploads
@@ -665,10 +668,6 @@ class AgentClient(BaseClient):
665
668
  return content
666
669
  return final_text
667
670
 
668
- def _handle_final_response_event(self, ev: dict[str, Any]) -> str:
669
- """Handle final response events."""
670
- return ev.get("content", "")
671
-
672
671
  def _handle_usage_event(
673
672
  self, ev: dict[str, Any], stats_usage: dict[str, Any]
674
673
  ) -> None:
@@ -708,11 +707,11 @@ class AgentClient(BaseClient):
708
707
  if kind == "artifact":
709
708
  return final_text, stats_usage
710
709
 
711
- # Accumulate assistant content
712
- if ev.get("content"):
710
+ # Handle different event types
711
+ if kind == "final_response" and ev.get("content"):
712
+ final_text = ev.get("content", "")
713
+ elif ev.get("content"):
713
714
  final_text = self._handle_content_event(ev, final_text)
714
- elif kind == "final_response" and ev.get("content"):
715
- final_text = self._handle_final_response_event(ev)
716
715
  elif kind == "usage":
717
716
  self._handle_usage_event(ev, stats_usage)
718
717
  elif kind == "run_info":
@@ -844,6 +843,75 @@ class AgentClient(BaseClient):
844
843
  r.on_complete(st)
845
844
  return final_payload
846
845
 
846
+ def _prepare_request_data(
847
+ self,
848
+ message: str,
849
+ files: list[str | BinaryIO] | None,
850
+ **kwargs,
851
+ ) -> tuple[dict | None, dict | None, dict | None, dict | None]:
852
+ """Prepare request data for async agent runs.
853
+
854
+ Returns:
855
+ Tuple of (payload, data_payload, files_payload, headers)
856
+ """
857
+ if files:
858
+ # Handle multipart data for file uploads
859
+ multipart_data = prepare_multipart_data(message, files)
860
+ # Inject optional multipart extras expected by backend
861
+ if "chat_history" in kwargs and kwargs["chat_history"] is not None:
862
+ multipart_data.data["chat_history"] = kwargs["chat_history"]
863
+ if "pii_mapping" in kwargs and kwargs["pii_mapping"] is not None:
864
+ multipart_data.data["pii_mapping"] = kwargs["pii_mapping"]
865
+
866
+ headers = {"Accept": SSE_CONTENT_TYPE}
867
+ return None, multipart_data.data, multipart_data.files, headers
868
+ else:
869
+ # Simple JSON payload for text-only requests
870
+ payload = {"input": message, "stream": True, **kwargs}
871
+ headers = {"Accept": SSE_CONTENT_TYPE}
872
+ return payload, None, None, headers
873
+
874
+ def _create_async_client_config(
875
+ self, timeout: float | None, headers: dict | None
876
+ ) -> dict:
877
+ """Create async client configuration with proper headers and timeout."""
878
+ config = self._build_async_client(timeout or self.timeout)
879
+ if headers:
880
+ config["headers"] = {**config["headers"], **headers}
881
+ return config
882
+
883
+ async def _stream_agent_response(
884
+ self,
885
+ async_client: httpx.AsyncClient,
886
+ agent_id: str,
887
+ payload: dict | None,
888
+ data_payload: dict | None,
889
+ files_payload: dict | None,
890
+ headers: dict | None,
891
+ timeout_seconds: float,
892
+ agent_name: str | None,
893
+ ) -> AsyncGenerator[dict, None]:
894
+ """Stream the agent response and yield parsed JSON chunks."""
895
+ async with async_client.stream(
896
+ "POST",
897
+ f"/agents/{agent_id}/run",
898
+ json=payload,
899
+ data=data_payload,
900
+ files=files_payload,
901
+ headers=headers,
902
+ ) as stream_response:
903
+ stream_response.raise_for_status()
904
+
905
+ async for event in aiter_sse_events(
906
+ stream_response, timeout_seconds, agent_name
907
+ ):
908
+ try:
909
+ chunk = json.loads(event["data"])
910
+ yield chunk
911
+ except json.JSONDecodeError:
912
+ logger.debug("Non-JSON SSE fragment skipped")
913
+ continue
914
+
847
915
  async def arun_agent(
848
916
  self,
849
917
  agent_id: str,
@@ -870,74 +938,34 @@ class AgentClient(BaseClient):
870
938
  httpx.TimeoutException: When general timeout occurs
871
939
  Exception: For other unexpected errors
872
940
  """
873
- # Prepare multipart data if files are provided
874
- multipart_data = None
875
- headers = None # None means "don't override client defaults"
876
-
877
- if files:
878
- multipart_data = prepare_multipart_data(message, files)
879
- # Inject optional multipart extras expected by backend
880
- if "chat_history" in kwargs and kwargs["chat_history"] is not None:
881
- multipart_data.data["chat_history"] = kwargs["chat_history"]
882
- if "pii_mapping" in kwargs and kwargs["pii_mapping"] is not None:
883
- multipart_data.data["pii_mapping"] = kwargs["pii_mapping"]
884
- headers = None # Let httpx set proper multipart boundaries
941
+ # Prepare request data
942
+ payload, data_payload, files_payload, headers = self._prepare_request_data(
943
+ message, files, **kwargs
944
+ )
885
945
 
886
- # When streaming, explicitly prefer SSE
887
- headers = {**(headers or {}), "Accept": "text/event-stream"}
946
+ # Create async client configuration
947
+ async_client_config = self._create_async_client_config(timeout, headers)
888
948
 
889
- if files:
890
- payload = None
891
- # Use multipart data
892
- data_payload = multipart_data.data
893
- files_payload = multipart_data.files
894
- else:
895
- payload = {"input": message, **kwargs}
896
- # Explicitly send stream intent both ways
897
- payload["stream"] = True
898
- data_payload = None
899
- files_payload = None
900
-
901
- # Use timeout from parameter or instance default
902
- request_timeout = timeout or self.timeout
949
+ # Get execution timeout for streaming control
950
+ timeout_seconds = kwargs.get("timeout", DEFAULT_AGENT_RUN_TIMEOUT)
951
+ agent_name = kwargs.get("agent_name")
903
952
 
904
953
  try:
905
- async_client_config = self._build_async_client(request_timeout)
906
- if headers:
907
- async_client_config["headers"] = {
908
- **async_client_config["headers"],
909
- **headers,
910
- }
911
-
912
- # Create async client for this request
954
+ # Create async client and stream response
913
955
  async with httpx.AsyncClient(**async_client_config) as async_client:
914
- async with async_client.stream(
915
- "POST",
916
- f"/agents/{agent_id}/run",
917
- json=payload,
918
- data=data_payload,
919
- files=files_payload,
920
- headers=headers,
921
- ) as stream_response:
922
- stream_response.raise_for_status()
923
-
924
- # Get agent run timeout for execution control
925
- # Prefer parameter timeout, otherwise use default
926
- timeout_seconds = kwargs.get("timeout", DEFAULT_AGENT_RUN_TIMEOUT)
927
-
928
- agent_name = kwargs.get("agent_name")
929
-
930
- async for event in aiter_sse_events(
931
- stream_response, timeout_seconds, agent_name
932
- ):
933
- try:
934
- chunk = json.loads(event["data"])
935
- yield chunk
936
- except json.JSONDecodeError:
937
- logger.debug("Non-JSON SSE fragment skipped")
938
- continue
956
+ async for chunk in self._stream_agent_response(
957
+ async_client,
958
+ agent_id,
959
+ payload,
960
+ data_payload,
961
+ files_payload,
962
+ headers,
963
+ timeout_seconds,
964
+ agent_name,
965
+ ):
966
+ yield chunk
939
967
 
940
968
  finally:
941
- # Ensure we close any opened file handles from multipart
942
- if multipart_data:
943
- multipart_data.close()
969
+ # Ensure cleanup - this is handled by the calling context
970
+ # but we keep this for safety in case of future changes
971
+ pass
glaip_sdk/client/base.py CHANGED
@@ -254,25 +254,25 @@ class BaseClient:
254
254
  client_log.error(f"Retry failed for {method} {endpoint}: {e}")
255
255
  raise e
256
256
 
257
- def _handle_response(self, response: httpx.Response) -> Any:
258
- """Handle HTTP response with proper error handling."""
257
+ def _parse_response_content(self, response: httpx.Response) -> Any | None:
258
+ """Parse response content based on content type."""
259
259
  if response.status_code == 204:
260
260
  return None
261
261
 
262
- parsed = None
263
262
  content_type = response.headers.get("content-type", "").lower()
264
263
  if "json" in content_type:
265
264
  try:
266
- parsed = response.json()
265
+ return response.json()
267
266
  except ValueError:
268
267
  pass
269
268
 
270
- if parsed is None:
271
- if 200 <= response.status_code < 300:
272
- return response.text
273
- else:
274
- self._raise_api_error(response.status_code, response.text)
269
+ if 200 <= response.status_code < 300:
270
+ return response.text
271
+ else:
272
+ return None # Let _handle_response deal with error status codes
275
273
 
274
+ def _handle_success_response(self, parsed: Any) -> Any:
275
+ """Handle successful response with success flag."""
276
276
  if isinstance(parsed, dict) and "success" in parsed:
277
277
  if parsed.get("success"):
278
278
  return parsed.get("data", parsed)
@@ -280,14 +280,45 @@ class BaseClient:
280
280
  error_type = parsed.get("error", "UnknownError")
281
281
  message = parsed.get("message", "Unknown error")
282
282
  self._raise_api_error(
283
- response.status_code, message, error_type, payload=parsed
283
+ 400,
284
+ message,
285
+ error_type,
286
+ payload=parsed, # Using 400 as status since original response had error
284
287
  )
285
288
 
286
- if 200 <= response.status_code < 300:
287
- return parsed
289
+ return parsed
290
+
291
+ def _get_error_message(self, response: httpx.Response) -> str:
292
+ """Extract error message from response, preferring parsed content."""
293
+ # Try to get error message from parsed content if available
294
+ error_message = response.text
295
+ try:
296
+ parsed = response.json()
297
+ if isinstance(parsed, dict) and "message" in parsed:
298
+ error_message = parsed["message"]
299
+ elif isinstance(parsed, str):
300
+ error_message = parsed
301
+ except (ValueError, TypeError):
302
+ pass # Use response.text as fallback
303
+ return error_message
304
+
305
+ def _handle_response(self, response: httpx.Response) -> Any:
306
+ """Handle HTTP response with proper error handling."""
307
+ # Handle no-content success before general error handling
308
+ if response.status_code == 204:
309
+ return None
310
+
311
+ # Handle error status codes
312
+ if not (200 <= response.status_code < 300):
313
+ error_message = self._get_error_message(response)
314
+ self._raise_api_error(response.status_code, error_message)
315
+ return None # Won't be reached but helps with type checking
316
+
317
+ parsed = self._parse_response_content(response)
318
+ if parsed is None:
319
+ return None
288
320
 
289
- message = parsed.get("message") if isinstance(parsed, dict) else str(parsed)
290
- self._raise_api_error(response.status_code, message, payload=parsed)
321
+ return self._handle_success_response(parsed)
291
322
 
292
323
  def _raise_api_error(
293
324
  self,
glaip_sdk/client/tools.py CHANGED
@@ -208,6 +208,44 @@ class ToolClient(BaseClient):
208
208
 
209
209
  return payload
210
210
 
211
+ def _handle_description_update(
212
+ self, update_data: dict[str, Any], description: str | None, current_tool: Tool
213
+ ) -> None:
214
+ """Handle description field in update payload."""
215
+ if description is not None:
216
+ update_data["description"] = description.strip()
217
+ elif hasattr(current_tool, "description") and current_tool.description:
218
+ update_data["description"] = current_tool.description
219
+
220
+ def _handle_tags_update(
221
+ self, update_data: dict[str, Any], kwargs: dict[str, Any], current_tool: Tool
222
+ ) -> None:
223
+ """Handle tags field in update payload."""
224
+ if kwargs.get("tags"):
225
+ if isinstance(kwargs["tags"], list):
226
+ update_data["tags"] = ",".join(
227
+ str(tag).strip() for tag in kwargs["tags"]
228
+ )
229
+ else:
230
+ update_data["tags"] = str(kwargs["tags"])
231
+ elif hasattr(current_tool, "tags") and current_tool.tags:
232
+ # Preserve existing tags if present
233
+ if isinstance(current_tool.tags, list):
234
+ update_data["tags"] = ",".join(
235
+ str(tag).strip() for tag in current_tool.tags
236
+ )
237
+ else:
238
+ update_data["tags"] = str(current_tool.tags)
239
+
240
+ def _handle_additional_kwargs(
241
+ self, update_data: dict[str, Any], kwargs: dict[str, Any]
242
+ ) -> None:
243
+ """Handle additional kwargs in update payload."""
244
+ excluded_keys = {"tags", "framework", "version"}
245
+ for key, value in kwargs.items():
246
+ if key not in excluded_keys:
247
+ update_data[key] = value
248
+
211
249
  def _build_update_payload(
212
250
  self,
213
251
  current_tool: Tool,
@@ -242,34 +280,14 @@ class ToolClient(BaseClient):
242
280
  ),
243
281
  }
244
282
 
245
- # Handle description with proper None handling
246
- if description is not None:
247
- update_data["description"] = description.strip()
248
- elif hasattr(current_tool, "description") and current_tool.description:
249
- update_data["description"] = current_tool.description
283
+ # Handle description update
284
+ self._handle_description_update(update_data, description, current_tool)
250
285
 
251
- # Handle tags - convert list to comma-separated string for API
252
- if kwargs.get("tags"):
253
- if isinstance(kwargs["tags"], list):
254
- update_data["tags"] = ",".join(
255
- str(tag).strip() for tag in kwargs["tags"]
256
- )
257
- else:
258
- update_data["tags"] = str(kwargs["tags"])
259
- elif hasattr(current_tool, "tags") and current_tool.tags:
260
- # Preserve existing tags if present
261
- if isinstance(current_tool.tags, list):
262
- update_data["tags"] = ",".join(
263
- str(tag).strip() for tag in current_tool.tags
264
- )
265
- else:
266
- update_data["tags"] = str(current_tool.tags)
286
+ # Handle tags update
287
+ self._handle_tags_update(update_data, kwargs, current_tool)
267
288
 
268
- # Add any other kwargs (excluding already handled ones)
269
- excluded_keys = {"tags", "framework", "version"}
270
- for key, value in kwargs.items():
271
- if key not in excluded_keys:
272
- update_data[key] = value
289
+ # Handle additional kwargs
290
+ self._handle_additional_kwargs(update_data, kwargs)
273
291
 
274
292
  return update_data
275
293
 
glaip_sdk/models.py CHANGED
@@ -119,6 +119,7 @@ class Tool(BaseModel):
119
119
  version: str | None = None
120
120
  tool_script: str | None = None
121
121
  tool_file: str | None = None
122
+ tags: str | list[str] | None = None
122
123
  _client: Any = None # Will hold client reference
123
124
 
124
125
  def _set_client(self, client: Any) -> "Tool":
@@ -246,6 +246,33 @@ def _handle_streaming_error(
246
246
  raise
247
247
 
248
248
 
249
+ def _process_sse_line(
250
+ line: str, buf: list[str], event_type: str | None, event_id: str | None
251
+ ) -> tuple[list[str], str | None, str | None, dict[str, Any] | None, bool]:
252
+ """Process a single SSE line and return updated state."""
253
+ result = _parse_sse_line(line, buf, event_type, event_id)
254
+ buf, event_type, event_id, event_data, completed = result
255
+ return buf, event_type, event_id, event_data, completed
256
+
257
+
258
+ def _yield_event_data(event_data: dict[str, Any] | None) -> Iterator[dict[str, Any]]:
259
+ """Yield event data if available."""
260
+ if event_data:
261
+ yield event_data
262
+
263
+
264
+ def _flush_remaining_buffer(
265
+ buf: list[str], event_type: str | None, event_id: str | None
266
+ ) -> Iterator[dict[str, Any]]:
267
+ """Flush any remaining data in buffer."""
268
+ if buf:
269
+ yield {
270
+ "event": event_type or "message",
271
+ "id": event_id,
272
+ "data": "\n".join(buf),
273
+ }
274
+
275
+
249
276
  def iter_sse_events(
250
277
  response: httpx.Response,
251
278
  timeout_seconds: float | None = None,
@@ -276,25 +303,16 @@ def iter_sse_events(
276
303
  if line is None:
277
304
  continue
278
305
 
279
- result = _parse_sse_line(line, buf, event_type, event_id)
280
- if len(result) == 5: # completion signal included
281
- buf, event_type, event_id, event_data, completed = result
282
- else: # normal case
283
- buf, event_type, event_id, event_data = result
284
- completed = False
306
+ buf, event_type, event_id, event_data, completed = _process_sse_line(
307
+ line, buf, event_type, event_id
308
+ )
285
309
 
286
- if event_data:
287
- yield event_data
310
+ yield from _yield_event_data(event_data)
288
311
  if completed:
289
312
  return
290
313
 
291
314
  # Flush any remaining data
292
- if buf:
293
- yield {
294
- "event": event_type or "message",
295
- "id": event_id,
296
- "data": "\n".join(buf),
297
- }
315
+ yield from _flush_remaining_buffer(buf, event_type, event_id)
298
316
 
299
317
  except Exception as e:
300
318
  _handle_streaming_error(e, timeout_seconds, agent_name)
@@ -329,11 +347,7 @@ async def aiter_sse_events(
329
347
  continue
330
348
 
331
349
  result = _parse_sse_line(line, buf, event_type, event_id)
332
- if len(result) == 5: # completion signal included
333
- buf, event_type, event_id, event_data, completed = result
334
- else: # normal case
335
- buf, event_type, event_id, event_data = result
336
- completed = False
350
+ buf, event_type, event_id, event_data, completed = result
337
351
 
338
352
  if event_data:
339
353
  yield event_data
@@ -352,6 +366,66 @@ async def aiter_sse_events(
352
366
  _handle_streaming_error(e, timeout_seconds, agent_name)
353
367
 
354
368
 
369
+ def _create_form_data(message: str) -> dict[str, Any]:
370
+ """Create form data with message and stream flag."""
371
+ return {"input": message, "message": message, "stream": True}
372
+
373
+
374
+ def _prepare_file_entry(
375
+ item: str | BinaryIO, stack: ExitStack
376
+ ) -> tuple[str, tuple[str, BinaryIO, str]]:
377
+ """Prepare a single file entry for multipart data."""
378
+ if isinstance(item, str):
379
+ return _prepare_path_entry(item, stack)
380
+ else:
381
+ return _prepare_stream_entry(item)
382
+
383
+
384
+ def _prepare_path_entry(
385
+ path_str: str, stack: ExitStack
386
+ ) -> tuple[str, tuple[str, BinaryIO, str]]:
387
+ """Prepare a file path entry."""
388
+ file_path = Path(path_str)
389
+ if not file_path.exists():
390
+ raise FileNotFoundError(f"File not found: {path_str}")
391
+
392
+ handle = stack.enter_context(open(file_path, "rb"))
393
+ return (
394
+ "files",
395
+ (
396
+ file_path.name,
397
+ handle,
398
+ "application/octet-stream",
399
+ ),
400
+ )
401
+
402
+
403
+ def _prepare_stream_entry(
404
+ file_obj: BinaryIO,
405
+ ) -> tuple[str, tuple[str, BinaryIO, str]]:
406
+ """Prepare a file object entry."""
407
+ if not hasattr(file_obj, "read"):
408
+ raise ValueError(f"Invalid file object: {file_obj}")
409
+
410
+ raw_name = getattr(file_obj, "name", "file")
411
+ filename = Path(raw_name).name if raw_name else "file"
412
+
413
+ try:
414
+ if hasattr(file_obj, "seek"):
415
+ file_obj.seek(0)
416
+ except (OSError, ValueError):
417
+ pass
418
+
419
+ return (
420
+ "files",
421
+ (
422
+ filename,
423
+ file_obj,
424
+ "application/octet-stream",
425
+ ),
426
+ )
427
+
428
+
355
429
  def prepare_multipart_data(message: str, files: list[str | BinaryIO]) -> MultipartData:
356
430
  """Prepare multipart form data for file uploads.
357
431
 
@@ -366,63 +440,13 @@ def prepare_multipart_data(message: str, files: list[str | BinaryIO]) -> Multipa
366
440
  FileNotFoundError: When a file path doesn't exist
367
441
  ValueError: When a file object is invalid
368
442
  """
369
-
370
- def _prepare_path_entry(
371
- path_str: str, stack: ExitStack
372
- ) -> tuple[str, tuple[str, BinaryIO, str]]:
373
- file_path = Path(path_str)
374
- if not file_path.exists():
375
- raise FileNotFoundError(f"File not found: {path_str}")
376
-
377
- handle = stack.enter_context(open(file_path, "rb"))
378
- return (
379
- "files",
380
- (
381
- file_path.name,
382
- handle,
383
- "application/octet-stream",
384
- ),
385
- )
386
-
387
- def _prepare_stream_entry(
388
- file_obj: BinaryIO,
389
- ) -> tuple[str, tuple[str, BinaryIO, str]]:
390
- if not hasattr(file_obj, "read"):
391
- raise ValueError(f"Invalid file object: {file_obj}")
392
-
393
- raw_name = getattr(file_obj, "name", "file")
394
- filename = Path(raw_name).name if raw_name else "file"
395
-
396
- try:
397
- if hasattr(file_obj, "seek"):
398
- file_obj.seek(0)
399
- except (OSError, ValueError):
400
- pass
401
-
402
- return (
403
- "files",
404
- (
405
- filename,
406
- file_obj,
407
- "application/octet-stream",
408
- ),
409
- )
410
-
411
- # Backend expects 'input' for the main prompt. Keep 'message' for
412
- # backward-compatibility with any legacy handlers.
413
- form_data = {"input": message, "message": message, "stream": True}
443
+ form_data = _create_form_data(message)
414
444
  stack = ExitStack()
415
445
  multipart_data = MultipartData(form_data, [])
416
446
  multipart_data._exit_stack = stack
417
447
 
418
448
  try:
419
- file_entries = []
420
- for item in files:
421
- if isinstance(item, str):
422
- file_entries.append(_prepare_path_entry(item, stack))
423
- else:
424
- file_entries.append(_prepare_stream_entry(item))
425
-
449
+ file_entries = [_prepare_file_entry(item, stack) for item in files]
426
450
  multipart_data.files = file_entries
427
451
  return multipart_data
428
452
  except Exception:
@@ -40,7 +40,9 @@ def extract_ids_from_export(items: list[Any]) -> list[str]:
40
40
  return ids
41
41
 
42
42
 
43
- def convert_export_to_import_format(data: dict[str, Any]) -> dict[str, Any]:
43
+ def convert_export_to_import_format(
44
+ data: dict[str, Any],
45
+ ) -> dict[str, Any]:
44
46
  """Convert export format to import-compatible format (extract IDs from objects).
45
47
 
46
48
  Args: