glaip-sdk 0.0.6a0__py3-none-any.whl → 0.0.8__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.
glaip_sdk/cli/utils.py CHANGED
@@ -8,13 +8,17 @@ from __future__ import annotations
8
8
 
9
9
  import io
10
10
  import json
11
+ import logging
11
12
  import os
12
13
  import platform
13
14
  import shlex
14
15
  import shutil
15
16
  import subprocess
17
+ import sys
16
18
  import tempfile
17
19
  from collections.abc import Callable
20
+ from contextlib import AbstractContextManager, nullcontext
21
+ from pathlib import Path
18
22
  from typing import TYPE_CHECKING, Any
19
23
 
20
24
  import click
@@ -29,15 +33,15 @@ try:
29
33
  from prompt_toolkit.shortcuts import prompt
30
34
 
31
35
  _HAS_PTK = True
32
- except Exception:
36
+ except Exception: # pragma: no cover - optional dependency
33
37
  _HAS_PTK = False
34
38
 
35
39
  try:
36
40
  import questionary
37
- except Exception:
41
+ except Exception: # pragma: no cover - optional dependency
38
42
  questionary = None
39
43
 
40
- if TYPE_CHECKING:
44
+ if TYPE_CHECKING: # pragma: no cover - import-only during type checking
41
45
  from glaip_sdk import Client
42
46
  from glaip_sdk.cli.commands.configure import load_config
43
47
  from glaip_sdk.rich_components import AIPPanel, AIPTable
@@ -49,6 +53,7 @@ from glaip_sdk.utils.rendering.renderer import (
49
53
  )
50
54
 
51
55
  console = Console()
56
+ logger = logging.getLogger("glaip_sdk.cli.utils")
52
57
 
53
58
 
54
59
  # ----------------------------- Context helpers ---------------------------- #
@@ -100,7 +105,7 @@ def _prepare_pager_env(
100
105
 
101
106
  def _render_ansi(
102
107
  renderable: Any,
103
- ) -> str: # pragma: no cover - rendering requires real terminal
108
+ ) -> str:
104
109
  """Render a Rich renderable to an ANSI string suitable for piping to 'less'."""
105
110
  buf = io.StringIO()
106
111
  tmp_console = Console(
@@ -116,7 +121,7 @@ def _render_ansi(
116
121
  return buf.getvalue()
117
122
 
118
123
 
119
- def _pager_header() -> str: # pragma: no cover - terminal UI helper
124
+ def _pager_header() -> str:
120
125
  v = (os.getenv("AIP_PAGER_HEADER", "1") or "1").strip().lower()
121
126
  if v in {"0", "false", "off"}:
122
127
  return ""
@@ -224,10 +229,80 @@ def _get_view(ctx: Any) -> str:
224
229
  return fallback or "rich"
225
230
 
226
231
 
232
+ def spinner_context(
233
+ ctx: Any | None,
234
+ message: str,
235
+ *,
236
+ console_override: Console | None = None,
237
+ spinner: str = "dots",
238
+ spinner_style: str = "cyan",
239
+ ) -> AbstractContextManager[Any]:
240
+ """Return a context manager that renders a spinner when appropriate."""
241
+
242
+ active_console = console_override or console
243
+ if not _can_use_spinner(ctx, active_console):
244
+ return nullcontext()
245
+
246
+ return active_console.status(
247
+ message,
248
+ spinner=spinner,
249
+ spinner_style=spinner_style,
250
+ )
251
+
252
+
253
+ def _can_use_spinner(ctx: Any | None, active_console: Console) -> bool:
254
+ """Check if spinner output is allowed in the current environment."""
255
+
256
+ if ctx is not None:
257
+ tty_enabled = bool(get_ctx_value(ctx, "tty", True))
258
+ view = (_get_view(ctx) or "rich").lower()
259
+ if not tty_enabled or view not in {"", "rich"}:
260
+ return False
261
+
262
+ if not active_console.is_terminal:
263
+ return False
264
+
265
+ return _stream_supports_tty(getattr(active_console, "file", None))
266
+
267
+
268
+ def _stream_supports_tty(stream: Any) -> bool:
269
+ """Return True if the provided stream can safely render a spinner."""
270
+
271
+ target = stream if hasattr(stream, "isatty") else sys.stdout
272
+ try:
273
+ return bool(target.isatty())
274
+ except Exception:
275
+ return False
276
+
277
+
278
+ def _spinner_update(status_indicator: Any | None, message: str) -> None:
279
+ """Update spinner text when a status indicator is active."""
280
+
281
+ if status_indicator is None:
282
+ return
283
+
284
+ try:
285
+ status_indicator.update(message)
286
+ except Exception: # pragma: no cover - defensive update
287
+ pass
288
+
289
+
290
+ def _spinner_stop(status_indicator: Any | None) -> None:
291
+ """Stop an active spinner safely."""
292
+
293
+ if status_indicator is None:
294
+ return
295
+
296
+ try:
297
+ status_indicator.stop()
298
+ except Exception: # pragma: no cover - defensive stop
299
+ pass
300
+
301
+
227
302
  # ----------------------------- Client config ----------------------------- #
228
303
 
229
304
 
230
- def get_client(ctx: Any) -> Client:
305
+ def get_client(ctx: Any) -> Client: # pragma: no cover
231
306
  """Get configured client from context, env, and config file (ctx > env > file)."""
232
307
  from glaip_sdk import Client
233
308
 
@@ -433,7 +508,9 @@ class _FuzzyCompleter:
433
508
  def __init__(self, words: list[str]) -> None:
434
509
  self.words = words
435
510
 
436
- def get_completions(self, document: Any, _complete_event: Any) -> Any:
511
+ def get_completions(
512
+ self, document: Any, _complete_event: Any
513
+ ) -> Any: # pragma: no cover
437
514
  word = document.get_word_before_cursor()
438
515
  if not word:
439
516
  return
@@ -444,7 +521,7 @@ class _FuzzyCompleter:
444
521
  if self._fuzzy_match(word_lower, label_lower):
445
522
  yield Completion(label, start_position=-len(word))
446
523
 
447
- def _fuzzy_match(self, search: str, target: str) -> bool:
524
+ def _fuzzy_match(self, search: str, target: str) -> bool: # pragma: no cover
448
525
  """True fuzzy matching: checks if all characters in search appear in order in target."""
449
526
  if not search:
450
527
  return True
@@ -502,47 +579,39 @@ def _fuzzy_pick(
502
579
  complete_in_thread=True,
503
580
  complete_while_typing=True,
504
581
  )
505
- except (KeyboardInterrupt, EOFError):
582
+ except (KeyboardInterrupt, EOFError): # pragma: no cover - user cancelled input
583
+ return None
584
+ except Exception: # pragma: no cover - prompt_toolkit not available in headless env
506
585
  return None
507
586
 
508
587
  return _perform_fuzzy_search(answer, labels, by_label) if answer else None
509
588
 
510
589
 
511
- def _fuzzy_score(search: str, target: str) -> int:
512
- """
513
- Calculate fuzzy match score.
514
- Higher score = better match.
515
- Returns -1 if no match possible.
516
- """
590
+ def _is_fuzzy_match(search: str, target: str) -> bool:
591
+ """Check if search string is a fuzzy match for target."""
517
592
  if not search:
518
- return 0
593
+ return True
519
594
 
520
- # Check if it's a fuzzy match first
521
595
  search_idx = 0
522
596
  for char in target:
523
597
  if search_idx < len(search) and search[search_idx] == char:
524
598
  search_idx += 1
525
599
  if search_idx == len(search):
526
- break
600
+ return True
601
+ return False
527
602
 
528
- if search_idx < len(search):
529
- return -1 # Not a fuzzy match
530
603
 
531
- # Calculate score based on:
532
- # 1. Exact substring match gets bonus points
533
- # 2. Consecutive character matches get bonus points
534
- # 3. Shorter search terms get bonus points
604
+ def _calculate_exact_match_bonus(search: str, target: str) -> int:
605
+ """Calculate bonus for exact substring matches."""
606
+ return 100 if search.lower() in target.lower() else 0
535
607
 
536
- score = 0
537
608
 
538
- # Exact substring bonus
539
- if search.lower() in target.lower():
540
- score += 100
541
-
542
- # Consecutive character bonus
609
+ def _calculate_consecutive_bonus(search: str, target: str) -> int:
610
+ """Calculate bonus for consecutive character matches."""
543
611
  consecutive = 0
544
612
  max_consecutive = 0
545
613
  search_idx = 0
614
+
546
615
  for char in target:
547
616
  if search_idx < len(search) and search[search_idx] == char:
548
617
  consecutive += 1
@@ -551,10 +620,31 @@ def _fuzzy_score(search: str, target: str) -> int:
551
620
  else:
552
621
  consecutive = 0
553
622
 
554
- score += max_consecutive * 10
623
+ return max_consecutive * 10
624
+
625
+
626
+ def _calculate_length_bonus(search: str, target: str) -> int:
627
+ """Calculate bonus for shorter search terms."""
628
+ return (len(target) - len(search)) * 2
629
+
630
+
631
+ def _fuzzy_score(search: str, target: str) -> int:
632
+ """
633
+ Calculate fuzzy match score.
634
+ Higher score = better match.
635
+ Returns -1 if no match possible.
636
+ """
637
+ if not search:
638
+ return 0
639
+
640
+ if not _is_fuzzy_match(search, target):
641
+ return -1 # Not a fuzzy match
555
642
 
556
- # Length bonus (shorter searches get higher scores)
557
- score += (len(target) - len(search)) * 2
643
+ # Calculate score based on different factors
644
+ score = 0
645
+ score += _calculate_exact_match_bonus(search, target)
646
+ score += _calculate_consecutive_bonus(search, target)
647
+ score += _calculate_length_bonus(search, target)
558
648
 
559
649
  return score
560
650
 
@@ -784,11 +874,14 @@ def _handle_fuzzy_pick_selection(
784
874
  rows: list[dict[str, Any]], columns: list[tuple], title: str
785
875
  ) -> bool:
786
876
  """Handle fuzzy picker selection, returns True if selection was made."""
787
- picked = (
788
- _fuzzy_pick(rows, columns, title)
789
- if console.is_terminal and os.isatty(1)
790
- else None
791
- )
877
+ picked = None
878
+ if console.is_terminal and os.isatty(1):
879
+ try:
880
+ picked = _fuzzy_pick(rows, columns, title)
881
+ except Exception:
882
+ logger.debug(
883
+ "Fuzzy picker failed; falling back to table output", exc_info=True
884
+ )
792
885
  if picked:
793
886
  table = _create_table(columns, title)
794
887
  table.add_row(*[str(picked.get(key, "N/A")) for key, _, _, _ in columns])
@@ -1082,6 +1175,7 @@ def resolve_resource(
1082
1175
  label: str,
1083
1176
  select: int | None = None,
1084
1177
  interface_preference: str = "fuzzy",
1178
+ status_indicator: Any | None = None,
1085
1179
  ) -> Any | None:
1086
1180
  """Resolve resource reference (ID or name) with ambiguity handling.
1087
1181
 
@@ -1093,32 +1187,46 @@ def resolve_resource(
1093
1187
  label: Resource type label for error messages
1094
1188
  select: Optional selection index for ambiguity resolution
1095
1189
  interface_preference: "fuzzy" for fuzzy picker, "questionary" for up/down list
1190
+ status_indicator: Optional Rich status indicator for wait animations
1096
1191
 
1097
1192
  Returns:
1098
1193
  Resolved resource object
1099
1194
  """
1195
+ spinner = status_indicator
1196
+ _spinner_update(spinner, f"[bold blue]Resolving {label}…[/bold blue]")
1197
+
1100
1198
  # Try to resolve by ID first
1199
+ _spinner_update(spinner, f"[bold blue]Fetching {label} by ID…[/bold blue]")
1101
1200
  result = _resolve_by_id(ref, get_by_id)
1102
1201
  if result is not None:
1202
+ _spinner_update(spinner, f"[bold green]{label} found[/bold green]")
1103
1203
  return result
1104
1204
 
1105
1205
  # If get_by_id returned None, the resource doesn't exist
1106
1206
  if is_uuid(ref):
1207
+ _spinner_stop(spinner)
1107
1208
  raise click.ClickException(f"{label} '{ref}' not found")
1108
1209
 
1109
1210
  # Find resources by name
1211
+ _spinner_update(
1212
+ spinner, f"[bold blue]Searching {label}s matching '{ref}'…[/bold blue]"
1213
+ )
1110
1214
  matches = find_by_name(name=ref)
1111
1215
  if not matches:
1216
+ _spinner_stop(spinner)
1112
1217
  raise click.ClickException(f"{label} '{ref}' not found")
1113
1218
 
1114
1219
  if len(matches) == 1:
1220
+ _spinner_update(spinner, f"[bold green]{label} found[/bold green]")
1115
1221
  return matches[0]
1116
1222
 
1117
1223
  # Multiple matches found, handle ambiguity
1118
1224
  if select:
1225
+ _spinner_stop(spinner)
1119
1226
  return _resolve_by_name_multiple_with_select(matches, select)
1120
1227
 
1121
1228
  # Choose interface based on preference
1229
+ _spinner_stop(spinner)
1122
1230
  if interface_preference == "fuzzy":
1123
1231
  return _resolve_by_name_multiple_fuzzy(ctx, ref, matches, label)
1124
1232
  else:
@@ -1222,3 +1330,19 @@ def handle_ambiguous_resource(
1222
1330
  else:
1223
1331
  # Re-raise cancellation exceptions
1224
1332
  raise
1333
+
1334
+
1335
+ def detect_export_format(file_path: str | Path) -> str:
1336
+ """Detect export format from file extension.
1337
+
1338
+ Args:
1339
+ file_path: Path to the export file
1340
+
1341
+ Returns:
1342
+ "yaml" if file extension is .yaml or .yml, "json" otherwise
1343
+ """
1344
+ path = Path(file_path)
1345
+ if path.suffix.lower() in [".yaml", ".yml"]:
1346
+ return "yaml"
1347
+ else:
1348
+ return "json"
@@ -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,