glaip-sdk 0.0.2__py3-none-any.whl → 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.
Files changed (40) hide show
  1. glaip_sdk/__init__.py +2 -2
  2. glaip_sdk/_version.py +51 -0
  3. glaip_sdk/branding.py +145 -0
  4. glaip_sdk/cli/commands/agents.py +876 -166
  5. glaip_sdk/cli/commands/configure.py +46 -104
  6. glaip_sdk/cli/commands/init.py +43 -118
  7. glaip_sdk/cli/commands/mcps.py +86 -161
  8. glaip_sdk/cli/commands/tools.py +196 -57
  9. glaip_sdk/cli/main.py +43 -29
  10. glaip_sdk/cli/utils.py +258 -27
  11. glaip_sdk/client/__init__.py +54 -2
  12. glaip_sdk/client/agents.py +196 -237
  13. glaip_sdk/client/base.py +62 -2
  14. glaip_sdk/client/mcps.py +63 -20
  15. glaip_sdk/client/tools.py +236 -81
  16. glaip_sdk/config/constants.py +10 -3
  17. glaip_sdk/exceptions.py +13 -0
  18. glaip_sdk/models.py +21 -5
  19. glaip_sdk/utils/__init__.py +116 -18
  20. glaip_sdk/utils/client_utils.py +284 -0
  21. glaip_sdk/utils/rendering/__init__.py +1 -0
  22. glaip_sdk/utils/rendering/formatting.py +211 -0
  23. glaip_sdk/utils/rendering/models.py +53 -0
  24. glaip_sdk/utils/rendering/renderer/__init__.py +38 -0
  25. glaip_sdk/utils/rendering/renderer/base.py +827 -0
  26. glaip_sdk/utils/rendering/renderer/config.py +33 -0
  27. glaip_sdk/utils/rendering/renderer/console.py +54 -0
  28. glaip_sdk/utils/rendering/renderer/debug.py +82 -0
  29. glaip_sdk/utils/rendering/renderer/panels.py +123 -0
  30. glaip_sdk/utils/rendering/renderer/progress.py +118 -0
  31. glaip_sdk/utils/rendering/renderer/stream.py +198 -0
  32. glaip_sdk/utils/rendering/steps.py +168 -0
  33. glaip_sdk/utils/run_renderer.py +22 -1086
  34. {glaip_sdk-0.0.2.dist-info → glaip_sdk-0.0.4.dist-info}/METADATA +8 -36
  35. glaip_sdk-0.0.4.dist-info/RECORD +41 -0
  36. glaip_sdk/cli/config.py +0 -592
  37. glaip_sdk/utils.py +0 -167
  38. glaip_sdk-0.0.2.dist-info/RECORD +0 -28
  39. {glaip_sdk-0.0.2.dist-info → glaip_sdk-0.0.4.dist-info}/WHEEL +0 -0
  40. {glaip_sdk-0.0.2.dist-info → glaip_sdk-0.0.4.dist-info}/entry_points.txt +0 -0
@@ -7,46 +7,36 @@ Authors:
7
7
 
8
8
  import json
9
9
  import logging
10
- import sys
10
+ from time import monotonic
11
11
  from typing import Any, BinaryIO
12
12
 
13
- import httpx
14
- from rich.console import Console
13
+ from rich.console import Console as _Console
15
14
 
16
15
  from glaip_sdk.client.base import BaseClient
16
+ from glaip_sdk.config.constants import (
17
+ DEFAULT_AGENT_FRAMEWORK,
18
+ DEFAULT_AGENT_PROVIDER,
19
+ DEFAULT_AGENT_RUN_TIMEOUT,
20
+ DEFAULT_AGENT_TYPE,
21
+ DEFAULT_AGENT_VERSION,
22
+ DEFAULT_MODEL,
23
+ )
17
24
  from glaip_sdk.models import Agent
18
- from glaip_sdk.utils.run_renderer import (
19
- RichStreamRenderer,
25
+ from glaip_sdk.utils.client_utils import (
26
+ create_model_instances,
27
+ extract_ids,
28
+ find_by_name,
29
+ iter_sse_events,
30
+ prepare_multipart_data,
20
31
  )
32
+ from glaip_sdk.utils.rendering.models import RunStats
33
+ from glaip_sdk.utils.rendering.renderer import RichStreamRenderer
34
+ from glaip_sdk.utils.rendering.renderer.config import RendererConfig
21
35
 
22
36
  # Set up module-level logger
23
37
  logger = logging.getLogger("glaip_sdk.agents")
24
38
 
25
39
 
26
- def _select_renderer(
27
- renderer: RichStreamRenderer | str | None, *, verbose: bool = False
28
- ) -> RichStreamRenderer:
29
- """Select the appropriate renderer based on input."""
30
- if isinstance(renderer, RichStreamRenderer):
31
- return renderer
32
-
33
- console = Console(file=sys.stdout, force_terminal=sys.stdout.isatty())
34
-
35
- if renderer in (None, "auto"):
36
- return RichStreamRenderer(console=console, verbose=verbose)
37
- if renderer == "json":
38
- # JSON output is handled by the renderer itself
39
- return RichStreamRenderer(console=console, verbose=verbose)
40
- if renderer == "markdown":
41
- # Markdown output is handled by the renderer itself
42
- return RichStreamRenderer(console=console, verbose=verbose)
43
- if renderer == "plain":
44
- # Plain output is handled by the renderer itself
45
- return RichStreamRenderer(console=console, verbose=verbose)
46
-
47
- raise ValueError(f"Unknown renderer: {renderer}")
48
-
49
-
50
40
  class AgentClient(BaseClient):
51
41
  """Client for agent operations."""
52
42
 
@@ -59,27 +49,10 @@ class AgentClient(BaseClient):
59
49
  """
60
50
  super().__init__(parent_client=parent_client, **kwargs)
61
51
 
62
- def _extract_ids(self, items: list[str | Any] | None) -> list[str] | None:
63
- """Extract IDs from a list of objects or strings."""
64
- if not items:
65
- return None
66
-
67
- ids = []
68
- for item in items:
69
- if isinstance(item, str):
70
- ids.append(item)
71
- elif hasattr(item, "id"):
72
- ids.append(item.id)
73
- else:
74
- # Fallback: convert to string
75
- ids.append(str(item))
76
-
77
- return ids
78
-
79
52
  def list_agents(self) -> list[Agent]:
80
53
  """List all agents."""
81
54
  data = self._request("GET", "/agents/")
82
- return [Agent(**agent_data)._set_client(self) for agent_data in (data or [])]
55
+ return create_model_instances(data, Agent, self)
83
56
 
84
57
  def get_agent_by_id(self, agent_id: str) -> Agent:
85
58
  """Get agent by ID."""
@@ -93,16 +66,19 @@ class AgentClient(BaseClient):
93
66
  params["name"] = name
94
67
 
95
68
  data = self._request("GET", "/agents/", params=params)
96
- return [Agent(**agent_data)._set_client(self) for agent_data in (data or [])]
69
+ agents = create_model_instances(data, Agent, self)
70
+ if name is None:
71
+ return agents
72
+ return find_by_name(agents, name, case_sensitive=False)
97
73
 
98
74
  def create_agent(
99
75
  self,
100
76
  name: str,
101
77
  instruction: str,
102
- model: str = "gpt-4.1",
78
+ model: str = DEFAULT_MODEL,
103
79
  tools: list[str | Any] | None = None,
104
80
  agents: list[str | Any] | None = None,
105
- timeout: int = 300,
81
+ timeout: int = DEFAULT_AGENT_RUN_TIMEOUT,
106
82
  **kwargs,
107
83
  ) -> "Agent":
108
84
  """Create a new agent."""
@@ -117,16 +93,20 @@ class AgentClient(BaseClient):
117
93
  raise ValueError("Agent instruction must be at least 10 characters long")
118
94
 
119
95
  # Prepare the creation payload
120
- payload = {
96
+ payload: dict[str, Any] = {
121
97
  "name": name.strip(),
122
98
  "instruction": instruction.strip(),
123
- "type": "config",
124
- "framework": "langchain",
125
- "version": "1.0",
126
- "provider": "openai",
127
- "model_name": model or "gpt-4.1", # Ensure model_name is never None
99
+ "type": DEFAULT_AGENT_TYPE,
100
+ "framework": DEFAULT_AGENT_FRAMEWORK,
101
+ "version": DEFAULT_AGENT_VERSION,
102
+ "provider": DEFAULT_AGENT_PROVIDER,
103
+ "model_name": model or DEFAULT_MODEL, # Ensure model_name is never None
128
104
  }
129
105
 
106
+ # Include default execution timeout if provided
107
+ if timeout is not None:
108
+ payload["timeout"] = str(timeout)
109
+
130
110
  # Ensure minimum required metadata for visibility
131
111
  if "metadata" not in kwargs:
132
112
  kwargs["metadata"] = {}
@@ -136,8 +116,8 @@ class AgentClient(BaseClient):
136
116
  kwargs["metadata"]["type"] = "custom"
137
117
 
138
118
  # Extract IDs from tool and agent objects
139
- tool_ids = self._extract_ids(tools)
140
- agent_ids = self._extract_ids(agents)
119
+ tool_ids = extract_ids(tools)
120
+ agent_ids = extract_ids(agents)
141
121
 
142
122
  # Add tools and agents if provided
143
123
  if tool_ids:
@@ -148,16 +128,13 @@ class AgentClient(BaseClient):
148
128
  # Add any additional kwargs
149
129
  payload.update(kwargs)
150
130
 
151
- # Create the agent
152
- data = self._request("POST", "/agents/", json=payload)
153
-
154
- # The backend only returns the ID, so we need to fetch the full agent details
155
- agent_id = data.get("id")
156
- if not agent_id:
157
- raise ValueError("Backend did not return agent ID")
158
-
159
- # Fetch the full agent details
160
- full_agent_data = self._request("GET", f"/agents/{agent_id}")
131
+ # Create the agent and fetch full details
132
+ full_agent_data = self._post_then_fetch(
133
+ id_key="id",
134
+ post_endpoint="/agents/",
135
+ get_endpoint_fmt="/agents/{id}",
136
+ json=payload,
137
+ )
161
138
  return Agent(**full_agent_data)._set_client(self)
162
139
 
163
140
  def update_agent(
@@ -178,14 +155,14 @@ class AgentClient(BaseClient):
178
155
  "instruction": instruction
179
156
  if instruction is not None
180
157
  else current_agent.instruction,
181
- "type": "config", # Required by backend
182
- "framework": "langchain", # Required by backend
183
- "version": "1.0", # Required by backend
158
+ "type": DEFAULT_AGENT_TYPE, # Required by backend
159
+ "framework": DEFAULT_AGENT_FRAMEWORK, # Required by backend
160
+ "version": DEFAULT_AGENT_VERSION, # Required by backend
184
161
  }
185
162
 
186
163
  # Handle model specification
187
164
  if model is not None:
188
- update_data["provider"] = "openai" # Default provider
165
+ update_data["provider"] = DEFAULT_AGENT_PROVIDER # Default provider
189
166
  update_data["model_name"] = model
190
167
  else:
191
168
  # Use current model if available
@@ -196,12 +173,12 @@ class AgentClient(BaseClient):
196
173
  update_data["model_name"] = current_agent.agent_config["lm_name"]
197
174
  else:
198
175
  # Default values
199
- update_data["provider"] = "openai"
200
- update_data["model_name"] = "gpt-4.1"
176
+ update_data["provider"] = DEFAULT_AGENT_PROVIDER
177
+ update_data["model_name"] = DEFAULT_MODEL
201
178
 
202
179
  # Handle tools and agents
203
180
  if "tools" in kwargs:
204
- tool_ids = self._extract_ids(kwargs["tools"])
181
+ tool_ids = extract_ids(kwargs["tools"])
205
182
  if tool_ids:
206
183
  update_data["tools"] = tool_ids
207
184
  elif current_agent.tools:
@@ -211,7 +188,7 @@ class AgentClient(BaseClient):
211
188
  ]
212
189
 
213
190
  if "agents" in kwargs:
214
- agent_ids = self._extract_ids(kwargs["agents"])
191
+ agent_ids = extract_ids(kwargs["agents"])
215
192
  if agent_ids:
216
193
  update_data["agents"] = agent_ids
217
194
  elif current_agent.agents:
@@ -239,30 +216,67 @@ class AgentClient(BaseClient):
239
216
  message: str,
240
217
  files: list[str | BinaryIO] | None = None,
241
218
  tty: bool = False,
242
- stream: bool = True,
243
219
  *,
244
220
  renderer: RichStreamRenderer | str | None = "auto",
245
- verbose: bool = False,
246
221
  **kwargs,
247
222
  ) -> str:
248
223
  """Run an agent with a message, streaming via a renderer."""
249
224
  # Prepare multipart data if files are provided
250
- form_data = None
251
- headers = {}
225
+ multipart_data = None
226
+ headers = None # None means "don't override client defaults"
227
+
228
+ if files:
229
+ multipart_data = prepare_multipart_data(message, files)
230
+ # Inject optional multipart extras expected by backend
231
+ if "chat_history" in kwargs and kwargs["chat_history"] is not None:
232
+ multipart_data.data["chat_history"] = kwargs["chat_history"]
233
+ if "pii_mapping" in kwargs and kwargs["pii_mapping"] is not None:
234
+ multipart_data.data["pii_mapping"] = kwargs["pii_mapping"]
235
+ headers = None # Let httpx set proper multipart boundaries
236
+
237
+ # When streaming, explicitly prefer SSE
238
+ headers = {**(headers or {}), "Accept": "text/event-stream"}
252
239
 
253
240
  if files:
254
- form_data = self._prepare_multipart_data(message, files)
255
- headers["Content-Type"] = "multipart/form-data"
256
241
  payload = None
242
+ # Use multipart data
243
+ data_payload = multipart_data.data
244
+ files_payload = multipart_data.files
257
245
  else:
258
246
  payload = {"input": message, **kwargs}
259
247
  if tty:
260
248
  payload["tty"] = True
261
- if not stream:
262
- payload["stream"] = False
263
-
264
- # Choose renderer (even if stream=False, we can still format final)
265
- r = _select_renderer(renderer, verbose=verbose)
249
+ # Explicitly send stream intent both ways
250
+ payload["stream"] = True
251
+ data_payload = None
252
+ files_payload = None
253
+
254
+ # Choose renderer: use provided instance or create a default
255
+ if isinstance(renderer, RichStreamRenderer):
256
+ r = renderer
257
+ else:
258
+ # Check if verbose mode is requested
259
+ verbose = kwargs.get("verbose", False)
260
+ if verbose:
261
+ # Create a verbose renderer similar to CLI --verbose
262
+ verbose_config = RendererConfig(
263
+ theme="dark",
264
+ style="debug", # CLI uses "debug" style for verbose
265
+ live=False, # CLI disables live updates for verbose
266
+ show_delegate_tool_panels=True, # CLI always shows tool panels
267
+ append_finished_snapshots=False,
268
+ )
269
+ r = RichStreamRenderer(
270
+ console=_Console(),
271
+ cfg=verbose_config,
272
+ verbose=True,
273
+ )
274
+ else:
275
+ # Default to a standard rich renderer with tool panels enabled
276
+ default_config = RendererConfig(
277
+ show_delegate_tool_panels=True, # Enable tool panels by default
278
+ )
279
+ r = RichStreamRenderer(console=_Console(), cfg=default_config)
266
280
 
267
281
  # Try to set some meta early; refine as we receive events
268
282
  meta = {
@@ -278,172 +292,117 @@ class AgentClient(BaseClient):
278
292
  started_monotonic = None
279
293
  finished_monotonic = None
280
294
 
281
- with self.http_client.stream(
282
- "POST",
283
- f"/agents/{agent_id}/run",
284
- json=payload if not files else None,
285
- data=form_data.get("data") if files else None,
286
- files=form_data.get("files") if files else None,
287
- headers=headers,
288
- ) as response:
289
- response.raise_for_status()
290
-
291
- # capture request id if provided
292
- req_id = response.headers.get("x-request-id") or response.headers.get(
293
- "x-run-id"
295
+ # MultipartData handles file cleanup automatically
296
+
297
+ try:
298
+ response = self.http_client.stream(
299
+ "POST",
300
+ f"/agents/{agent_id}/run",
301
+ json=payload,
302
+ data=data_payload,
303
+ files=files_payload,
304
+ headers=headers,
294
305
  )
295
- if req_id:
296
- meta["run_id"] = req_id
297
- r.on_start(meta) # refresh header with run_id
298
-
299
- for event in self._iter_sse_events(response):
300
- try:
301
- ev = json.loads(event["data"])
302
- except json.JSONDecodeError:
303
- logger.debug("Non-JSON SSE fragment skipped")
304
- continue
305
-
306
- # Start timer at first meaningful event
307
- if started_monotonic is None and (
308
- "content" in ev or "status" in ev or ev.get("metadata")
309
- ):
310
- from time import monotonic
311
306
 
312
- started_monotonic = monotonic()
307
+ with response as stream_response:
308
+ stream_response.raise_for_status()
313
309
 
314
- kind = (ev.get("metadata") or {}).get("kind")
310
+ # capture request id if provided
311
+ req_id = stream_response.headers.get(
312
+ "x-request-id"
313
+ ) or stream_response.headers.get("x-run-id")
314
+ if req_id:
315
+ meta["run_id"] = req_id
316
+ r.on_start(meta) # refresh header with run_id
315
317
 
316
- # Hide "artifact" chatter
317
- if kind == "artifact":
318
- continue
318
+ # Get agent run timeout for execution control
319
+ # Prefer CLI-provided timeout, otherwise use default
320
+ timeout_seconds = kwargs.get("timeout", DEFAULT_AGENT_RUN_TIMEOUT)
319
321
 
320
- # Accumulate assistant content, but do not print here
321
- if "content" in ev and ev["content"]:
322
- # Filter weird backend text like "Artifact received: ..."
323
- if not ev["content"].startswith("Artifact received:"):
324
- final_text = ev["content"] # replace with latest
325
- r.on_event(ev)
326
- continue
322
+ agent_name = kwargs.get("agent_name")
327
323
 
328
- # Step / tool events
329
- if kind == "agent_step":
330
- r.on_event(ev)
331
- continue
324
+ for event in iter_sse_events(
325
+ stream_response, timeout_seconds, agent_name
326
+ ):
327
+ try:
328
+ ev = json.loads(event["data"])
329
+ except json.JSONDecodeError:
330
+ logger.debug("Non-JSON SSE fragment skipped")
331
+ continue
332
332
 
333
- # Statuses: forward to renderer (it decides to collapse)
334
- if "status" in ev:
335
- r.on_event(ev)
336
- continue
333
+ # Start timer at first meaningful event
334
+ if started_monotonic is None and (
335
+ "content" in ev or "status" in ev or ev.get("metadata")
336
+ ):
337
+ started_monotonic = monotonic()
337
338
 
338
- # Usage/cost event (if your backend emits it)
339
- if kind == "usage":
340
- stats_usage.update(ev.get("usage") or {})
341
- continue
339
+ kind = (ev.get("metadata") or {}).get("kind")
342
340
 
343
- # Model/run info (if emitted mid-stream)
344
- if kind == "run_info":
345
- if ev.get("model"):
346
- meta["model"] = ev["model"]
347
- r.on_start(meta)
348
- if ev.get("run_id"):
349
- meta["run_id"] = ev["run_id"]
350
- r.on_start(meta)
341
+ # Pass event to the renderer (always, don't filter)
342
+ r.on_event(ev)
351
343
 
352
- from time import monotonic
344
+ # Hide "artifact" chatter from content accumulation only
345
+ if kind == "artifact":
346
+ continue
347
+
348
+ # Accumulate assistant content, but do not print here
349
+ if "content" in ev and ev["content"]:
350
+ # Filter weird backend text like "Artifact received: ..."
351
+ if not ev["content"].startswith("Artifact received:"):
352
+ final_text = ev["content"] # replace with latest
353
+ continue
354
+
355
+ # Also treat final_response like content for CLI return value
356
+ if kind == "final_response" and ev.get("content"):
357
+ final_text = ev["content"] # ensure CLI non-empty
358
+ continue
359
+
360
+ # Usage/cost event (if your backend emits it)
361
+ if kind == "usage":
362
+ stats_usage.update(ev.get("usage") or {})
363
+ continue
364
+
365
+ # Model/run info (if emitted mid-stream)
366
+ if kind == "run_info":
367
+ if ev.get("model"):
368
+ meta["model"] = ev["model"]
369
+ r.on_start(meta)
370
+ if ev.get("run_id"):
371
+ meta["run_id"] = ev["run_id"]
372
+ r.on_start(meta)
353
373
 
354
374
  finished_monotonic = monotonic()
375
+ except KeyboardInterrupt:
376
+ try:
377
+ r.close()
378
+ finally:
379
+ raise
380
+ except Exception:
381
+ try:
382
+ r.close()
383
+ finally:
384
+ raise
385
+ finally:
386
+ # Ensure we close any opened file handles from multipart
387
+ if multipart_data:
388
+ multipart_data.close()
355
389
 
356
390
  # Finalize stats
357
- from glaip_sdk.utils.run_renderer import RunStats
358
-
359
391
  st = RunStats()
392
+ # Ensure monotonic order (avoid negative -0.0s)
393
+ if started_monotonic is None:
394
+ started_monotonic = finished_monotonic
395
+
360
396
  st.started_at = started_monotonic or st.started_at
361
397
  st.finished_at = finished_monotonic or st.started_at
362
398
  st.usage = stats_usage
363
399
 
364
- r.on_complete(final_text or "No response content received.", st)
365
- return final_text or "No response content received."
366
-
367
- def _iter_sse_events(self, response: httpx.Response):
368
- """Iterate over Server-Sent Events with proper parsing."""
369
- buf = []
370
- event_type = None
371
- event_id = None
372
-
373
- for raw in response.iter_lines():
374
- line = raw.decode("utf-8") if isinstance(raw, bytes) else raw
375
-
376
- if line == "":
377
- if buf:
378
- data = "\n".join(buf)
379
- yield {
380
- "event": event_type or "message",
381
- "id": event_id,
382
- "data": data,
383
- }
384
- buf, event_type, event_id = [], None, None
385
- continue
386
-
387
- if line.startswith(":"): # comment
388
- continue
389
- if line.startswith("data:"):
390
- buf.append(line[5:].lstrip())
391
- elif line.startswith("event:"):
392
- event_type = line[6:].strip() or None
393
- elif line.startswith("id:"):
394
- event_id = line[3:].strip() or None
395
-
396
- # Flush any remaining data
397
- if buf:
398
- yield {
399
- "event": event_type or "message",
400
- "id": event_id,
401
- "data": "\n".join(buf),
402
- }
403
-
404
- def _prepare_multipart_data(
405
- self, message: str, files: list[str | BinaryIO]
406
- ) -> dict[str, Any]:
407
- """Prepare multipart form data for file uploads."""
408
- from pathlib import Path
409
-
410
- form_data = {"data": {"message": message}}
411
- file_list = []
412
-
413
- for file_item in files:
414
- if isinstance(file_item, str):
415
- # File path - let httpx stream the file handle
416
- file_path = Path(file_item)
417
- if not file_path.exists():
418
- raise FileNotFoundError(f"File not found: {file_item}")
419
-
420
- file_list.append(
421
- (
422
- "files",
423
- (
424
- file_path.name,
425
- open(file_path, "rb"),
426
- "application/octet-stream",
427
- ),
428
- )
429
- )
430
- else:
431
- # File-like object
432
- if hasattr(file_item, "name"):
433
- filename = getattr(file_item, "name", "file")
434
- else:
435
- filename = "file"
436
-
437
- if hasattr(file_item, "read"):
438
- # For file-like objects, we need to read them since httpx expects bytes
439
- file_content = file_item.read()
440
- file_list.append(
441
- ("files", (filename, file_content, "application/octet-stream"))
442
- )
443
- else:
444
- raise ValueError(f"Invalid file object: {file_item}")
445
-
446
- if file_list:
447
- form_data["files"] = file_list
448
-
449
- return form_data
400
+ # Prefer explicit content, otherwise fall back to what the renderer saw
401
+ if hasattr(r, "state") and hasattr(r.state, "buffer"):
402
+ rendered_text = "".join(r.state.buffer)
403
+ else:
404
+ rendered_text = ""
405
+ final_payload = final_text or rendered_text or "No response content received."
406
+
407
+ r.on_complete(st)
408
+ return final_payload
glaip_sdk/client/base.py CHANGED
@@ -12,6 +12,7 @@ from typing import Any, Union
12
12
  import httpx
13
13
  from dotenv import load_dotenv
14
14
 
15
+ from glaip_sdk.config.constants import SDK_NAME, SDK_VERSION
15
16
  from glaip_sdk.exceptions import (
16
17
  AuthenticationError,
17
18
  ConflictError,
@@ -82,7 +83,15 @@ class BaseClient:
82
83
 
83
84
  def _build_client(self, timeout: float) -> httpx.Client:
84
85
  """Build HTTP client with configuration."""
85
- from glaip_sdk.config.constants import SDK_NAME, SDK_VERSION
86
+ # For streaming operations, we need more generous read timeouts
87
+ # while keeping reasonable connect timeouts
88
+ timeout_config = httpx.Timeout(
89
+ timeout=timeout, # Total timeout
90
+ connect=min(30.0, timeout), # Connect timeout (max 30s)
91
+ read=timeout, # Read timeout (same as total for streaming)
92
+ write=min(30.0, timeout), # Write timeout (max 30s)
93
+ pool=timeout, # Pool timeout (same as total)
94
+ )
86
95
 
87
96
  return httpx.Client(
88
97
  base_url=self.api_url,
@@ -90,7 +99,7 @@ class BaseClient:
90
99
  "X-API-Key": self.api_key,
91
100
  "User-Agent": f"{SDK_NAME}/{SDK_VERSION}",
92
101
  },
93
- timeout=httpx.Timeout(timeout),
102
+ timeout=timeout_config,
94
103
  follow_redirects=True,
95
104
  http2=False,
96
105
  limits=httpx.Limits(max_keepalive_connections=10, max_connections=100),
@@ -113,6 +122,57 @@ class BaseClient:
113
122
  self.http_client.close()
114
123
  self.http_client = self._build_client(value)
115
124
 
125
+ def _post_then_fetch(
126
+ self,
127
+ id_key: str,
128
+ post_endpoint: str,
129
+ get_endpoint_fmt: str,
130
+ *,
131
+ json=None,
132
+ data=None,
133
+ files=None,
134
+ **kwargs,
135
+ ) -> Any:
136
+ """Helper for POST-then-GET pattern used in create methods.
137
+
138
+ Args:
139
+ id_key: Key in POST response containing the ID
140
+ post_endpoint: Endpoint for POST request
141
+ get_endpoint_fmt: Format string for GET endpoint (e.g., "/items/{id}")
142
+ json: JSON data for POST
143
+ data: Form data for POST
144
+ files: Files for POST
145
+ **kwargs: Additional kwargs for POST
146
+
147
+ Returns:
148
+ Full resource data from GET request
149
+ """
150
+ # Create the resource
151
+ post_kwargs = {}
152
+ if json is not None:
153
+ post_kwargs["json"] = json
154
+ if data is not None:
155
+ post_kwargs["data"] = data
156
+ if files is not None:
157
+ post_kwargs["files"] = files
158
+ post_kwargs.update(kwargs)
159
+
160
+ response_data = self._request("POST", post_endpoint, **post_kwargs)
161
+
162
+ # Extract the ID
163
+ if isinstance(response_data, dict):
164
+ resource_id = response_data.get(id_key)
165
+ else:
166
+ # Fallback: assume response_data is the ID directly
167
+ resource_id = str(response_data)
168
+
169
+ if not resource_id:
170
+ raise ValueError(f"Backend did not return {id_key}")
171
+
172
+ # Fetch the full resource details
173
+ get_endpoint = get_endpoint_fmt.format(id=resource_id)
174
+ return self._request("GET", get_endpoint)
175
+
116
176
  def _request(self, method: str, endpoint: str, **kwargs) -> Any:
117
177
  """Make HTTP request with error handling."""
118
178
  client_log.debug(f"Making {method} request to {endpoint}")