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/_version.py +42 -19
- glaip_sdk/branding.py +3 -3
- glaip_sdk/cli/commands/agents.py +136 -38
- glaip_sdk/cli/commands/configure.py +2 -2
- glaip_sdk/cli/commands/mcps.py +2 -4
- glaip_sdk/cli/commands/tools.py +2 -4
- glaip_sdk/cli/display.py +15 -15
- glaip_sdk/cli/main.py +51 -5
- glaip_sdk/cli/resolution.py +17 -9
- glaip_sdk/cli/slash/__init__.py +25 -0
- glaip_sdk/cli/slash/agent_session.py +146 -0
- glaip_sdk/cli/slash/prompt.py +198 -0
- glaip_sdk/cli/slash/session.py +665 -0
- glaip_sdk/cli/utils.py +162 -38
- glaip_sdk/client/agents.py +101 -73
- glaip_sdk/client/base.py +45 -14
- glaip_sdk/client/tools.py +44 -26
- glaip_sdk/models.py +1 -0
- glaip_sdk/utils/client_utils.py +95 -71
- glaip_sdk/utils/import_export.py +3 -1
- glaip_sdk/utils/rendering/renderer/base.py +170 -125
- glaip_sdk/utils/serialization.py +52 -16
- {glaip_sdk-0.0.6a0.dist-info → glaip_sdk-0.0.8.dist-info}/METADATA +1 -1
- {glaip_sdk-0.0.6a0.dist-info → glaip_sdk-0.0.8.dist-info}/RECORD +26 -22
- {glaip_sdk-0.0.6a0.dist-info → glaip_sdk-0.0.8.dist-info}/WHEEL +0 -0
- {glaip_sdk-0.0.6a0.dist-info → glaip_sdk-0.0.8.dist-info}/entry_points.txt +0 -0
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:
|
|
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:
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
600
|
+
return True
|
|
601
|
+
return False
|
|
527
602
|
|
|
528
|
-
if search_idx < len(search):
|
|
529
|
-
return -1 # Not a fuzzy match
|
|
530
603
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
|
|
539
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
557
|
-
score
|
|
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
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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"
|
glaip_sdk/client/agents.py
CHANGED
|
@@ -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":
|
|
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
|
-
#
|
|
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
|
|
874
|
-
|
|
875
|
-
|
|
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
|
-
#
|
|
887
|
-
|
|
946
|
+
# Create async client configuration
|
|
947
|
+
async_client_config = self._create_async_client_config(timeout, headers)
|
|
888
948
|
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
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
|
-
|
|
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
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
headers
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
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
|
|
942
|
-
|
|
943
|
-
|
|
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
|
|
258
|
-
"""
|
|
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
|
-
|
|
265
|
+
return response.json()
|
|
267
266
|
except ValueError:
|
|
268
267
|
pass
|
|
269
268
|
|
|
270
|
-
if
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
283
|
+
400,
|
|
284
|
+
message,
|
|
285
|
+
error_type,
|
|
286
|
+
payload=parsed, # Using 400 as status since original response had error
|
|
284
287
|
)
|
|
285
288
|
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
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,
|