glaip-sdk 0.0.1b10__py3-none-any.whl → 0.0.3__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 (39) hide show
  1. glaip_sdk/__init__.py +2 -2
  2. glaip_sdk/_version.py +51 -0
  3. glaip_sdk/cli/commands/agents.py +201 -109
  4. glaip_sdk/cli/commands/configure.py +29 -87
  5. glaip_sdk/cli/commands/init.py +16 -7
  6. glaip_sdk/cli/commands/mcps.py +73 -153
  7. glaip_sdk/cli/commands/tools.py +185 -49
  8. glaip_sdk/cli/main.py +30 -27
  9. glaip_sdk/cli/utils.py +126 -13
  10. glaip_sdk/client/__init__.py +54 -2
  11. glaip_sdk/client/agents.py +175 -237
  12. glaip_sdk/client/base.py +62 -2
  13. glaip_sdk/client/mcps.py +63 -20
  14. glaip_sdk/client/tools.py +95 -28
  15. glaip_sdk/config/constants.py +10 -3
  16. glaip_sdk/exceptions.py +13 -0
  17. glaip_sdk/models.py +20 -4
  18. glaip_sdk/utils/__init__.py +116 -18
  19. glaip_sdk/utils/client_utils.py +284 -0
  20. glaip_sdk/utils/rendering/__init__.py +1 -0
  21. glaip_sdk/utils/rendering/formatting.py +211 -0
  22. glaip_sdk/utils/rendering/models.py +53 -0
  23. glaip_sdk/utils/rendering/renderer/__init__.py +38 -0
  24. glaip_sdk/utils/rendering/renderer/base.py +827 -0
  25. glaip_sdk/utils/rendering/renderer/config.py +33 -0
  26. glaip_sdk/utils/rendering/renderer/console.py +54 -0
  27. glaip_sdk/utils/rendering/renderer/debug.py +82 -0
  28. glaip_sdk/utils/rendering/renderer/panels.py +123 -0
  29. glaip_sdk/utils/rendering/renderer/progress.py +118 -0
  30. glaip_sdk/utils/rendering/renderer/stream.py +198 -0
  31. glaip_sdk/utils/rendering/steps.py +168 -0
  32. glaip_sdk/utils/run_renderer.py +22 -1086
  33. {glaip_sdk-0.0.1b10.dist-info → glaip_sdk-0.0.3.dist-info}/METADATA +9 -37
  34. glaip_sdk-0.0.3.dist-info/RECORD +40 -0
  35. glaip_sdk/cli/config.py +0 -592
  36. glaip_sdk/utils.py +0 -167
  37. glaip_sdk-0.0.1b10.dist-info/RECORD +0 -28
  38. {glaip_sdk-0.0.1b10.dist-info → glaip_sdk-0.0.3.dist-info}/WHEEL +0 -0
  39. {glaip_sdk-0.0.1b10.dist-info → glaip_sdk-0.0.3.dist-info}/entry_points.txt +0 -0
@@ -5,6 +5,8 @@ Authors:
5
5
  Raymond Christopher (raymond.christopher@gdplabs.id)
6
6
  """
7
7
 
8
+ from typing import Any
9
+
8
10
  from glaip_sdk.client.agents import AgentClient
9
11
  from glaip_sdk.client.base import BaseClient
10
12
  from glaip_sdk.client.mcps import MCPClient
@@ -104,10 +106,18 @@ class Client(BaseClient):
104
106
  return tool
105
107
 
106
108
  def create_tool_from_code(
107
- self, name: str, code: str, framework: str = "langchain"
109
+ self,
110
+ name: str,
111
+ code: str,
112
+ framework: str = "langchain",
113
+ *,
114
+ description: str | None = None,
115
+ tags: list[str] | None = None,
108
116
  ) -> Tool:
109
117
  """Create a new tool plugin from code string."""
110
- tool = self.tools.create_tool_from_code(name, code, framework)
118
+ tool = self.tools.create_tool_from_code(
119
+ name, code, framework, description=description, tags=tags
120
+ )
111
121
  tool._set_client(self)
112
122
  return tool
113
123
 
@@ -119,6 +129,16 @@ class Client(BaseClient):
119
129
  """Delete a tool by ID."""
120
130
  return self.tools.delete_tool(tool_id)
121
131
 
132
+ def get_tool_script(self, tool_id: str) -> str:
133
+ """Get tool script content."""
134
+ return self.tools.get_tool_script(tool_id)
135
+
136
+ def update_tool_via_file(self, tool_id: str, file_path: str, **kwargs) -> Tool:
137
+ """Update a tool plugin via file upload."""
138
+ tool = self.tools.update_tool_via_file(tool_id, file_path, **kwargs)
139
+ tool._set_client(self)
140
+ return tool
141
+
122
142
  # ---- MCPs
123
143
  def list_mcps(self) -> list[MCP]:
124
144
  mcps = self.mcps.list_mcps()
@@ -150,6 +170,18 @@ class Client(BaseClient):
150
170
  """Update an MCP by ID."""
151
171
  return self.mcps.update_mcp(mcp_id, **kwargs)
152
172
 
173
+ def test_mcp_connection(self, config: dict[str, Any]) -> dict[str, Any]:
174
+ """Test MCP connection using configuration."""
175
+ return self.mcps.test_mcp_connection(config)
176
+
177
+ def test_mcp_connection_from_config(self, config: dict[str, Any]) -> dict[str, Any]:
178
+ """Test MCP connection using configuration (alias)."""
179
+ return self.mcps.test_mcp_connection_from_config(config)
180
+
181
+ def get_mcp_tools_from_config(self, config: dict[str, Any]) -> list[dict[str, Any]]:
182
+ """Fetch tools from MCP configuration without saving."""
183
+ return self.mcps.get_mcp_tools_from_config(config)
184
+
153
185
  def run_agent(self, agent_id: str, message: str, **kwargs) -> str:
154
186
  """Run an agent with a message."""
155
187
  return self.agents.run_agent(agent_id, message, **kwargs)
@@ -160,6 +192,26 @@ class Client(BaseClient):
160
192
  data = self._request("GET", "/language-models")
161
193
  return data or []
162
194
 
195
+ # ---- Timeout propagation ----
196
+ @property
197
+ def timeout(self) -> float: # type: ignore[override]
198
+ return super().timeout
199
+
200
+ @timeout.setter
201
+ def timeout(self, value: float) -> None: # type: ignore[override]
202
+ # Rebuild the root http client
203
+ BaseClient.timeout.fset(self, value) # call parent setter
204
+ # Propagate the new session to sub-clients so they don't hold a closed client
205
+ try:
206
+ if hasattr(self, "agents"):
207
+ self.agents.http_client = self.http_client
208
+ if hasattr(self, "tools"):
209
+ self.tools.http_client = self.http_client
210
+ if hasattr(self, "mcps"):
211
+ self.mcps.http_client = self.http_client
212
+ except Exception:
213
+ pass
214
+
163
215
  # ---- Aliases (back-compat)
164
216
  def get_agent(self, agent_id: str) -> Agent:
165
217
  return self.get_agent_by_id(agent_id)
@@ -7,46 +7,35 @@ 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
21
34
 
22
35
  # Set up module-level logger
23
36
  logger = logging.getLogger("glaip_sdk.agents")
24
37
 
25
38
 
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
39
  class AgentClient(BaseClient):
51
40
  """Client for agent operations."""
52
41
 
@@ -59,27 +48,10 @@ class AgentClient(BaseClient):
59
48
  """
60
49
  super().__init__(parent_client=parent_client, **kwargs)
61
50
 
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
51
  def list_agents(self) -> list[Agent]:
80
52
  """List all agents."""
81
53
  data = self._request("GET", "/agents/")
82
- return [Agent(**agent_data)._set_client(self) for agent_data in (data or [])]
54
+ return create_model_instances(data, Agent, self)
83
55
 
84
56
  def get_agent_by_id(self, agent_id: str) -> Agent:
85
57
  """Get agent by ID."""
@@ -93,16 +65,19 @@ class AgentClient(BaseClient):
93
65
  params["name"] = name
94
66
 
95
67
  data = self._request("GET", "/agents/", params=params)
96
- return [Agent(**agent_data)._set_client(self) for agent_data in (data or [])]
68
+ agents = create_model_instances(data, Agent, self)
69
+ if name is None:
70
+ return agents
71
+ return find_by_name(agents, name, case_sensitive=False)
97
72
 
98
73
  def create_agent(
99
74
  self,
100
75
  name: str,
101
76
  instruction: str,
102
- model: str = "gpt-4.1",
77
+ model: str = DEFAULT_MODEL,
103
78
  tools: list[str | Any] | None = None,
104
79
  agents: list[str | Any] | None = None,
105
- timeout: int = 300,
80
+ timeout: int = DEFAULT_AGENT_RUN_TIMEOUT,
106
81
  **kwargs,
107
82
  ) -> "Agent":
108
83
  """Create a new agent."""
@@ -117,16 +92,20 @@ class AgentClient(BaseClient):
117
92
  raise ValueError("Agent instruction must be at least 10 characters long")
118
93
 
119
94
  # Prepare the creation payload
120
- payload = {
95
+ payload: dict[str, Any] = {
121
96
  "name": name.strip(),
122
97
  "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
98
+ "type": DEFAULT_AGENT_TYPE,
99
+ "framework": DEFAULT_AGENT_FRAMEWORK,
100
+ "version": DEFAULT_AGENT_VERSION,
101
+ "provider": DEFAULT_AGENT_PROVIDER,
102
+ "model_name": model or DEFAULT_MODEL, # Ensure model_name is never None
128
103
  }
129
104
 
105
+ # Include default execution timeout if provided
106
+ if timeout is not None:
107
+ payload["timeout"] = str(timeout)
108
+
130
109
  # Ensure minimum required metadata for visibility
131
110
  if "metadata" not in kwargs:
132
111
  kwargs["metadata"] = {}
@@ -136,8 +115,8 @@ class AgentClient(BaseClient):
136
115
  kwargs["metadata"]["type"] = "custom"
137
116
 
138
117
  # Extract IDs from tool and agent objects
139
- tool_ids = self._extract_ids(tools)
140
- agent_ids = self._extract_ids(agents)
118
+ tool_ids = extract_ids(tools)
119
+ agent_ids = extract_ids(agents)
141
120
 
142
121
  # Add tools and agents if provided
143
122
  if tool_ids:
@@ -148,16 +127,13 @@ class AgentClient(BaseClient):
148
127
  # Add any additional kwargs
149
128
  payload.update(kwargs)
150
129
 
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}")
130
+ # Create the agent and fetch full details
131
+ full_agent_data = self._post_then_fetch(
132
+ id_key="id",
133
+ post_endpoint="/agents/",
134
+ get_endpoint_fmt="/agents/{id}",
135
+ json=payload,
136
+ )
161
137
  return Agent(**full_agent_data)._set_client(self)
162
138
 
163
139
  def update_agent(
@@ -178,14 +154,14 @@ class AgentClient(BaseClient):
178
154
  "instruction": instruction
179
155
  if instruction is not None
180
156
  else current_agent.instruction,
181
- "type": "config", # Required by backend
182
- "framework": "langchain", # Required by backend
183
- "version": "1.0", # Required by backend
157
+ "type": DEFAULT_AGENT_TYPE, # Required by backend
158
+ "framework": DEFAULT_AGENT_FRAMEWORK, # Required by backend
159
+ "version": DEFAULT_AGENT_VERSION, # Required by backend
184
160
  }
185
161
 
186
162
  # Handle model specification
187
163
  if model is not None:
188
- update_data["provider"] = "openai" # Default provider
164
+ update_data["provider"] = DEFAULT_AGENT_PROVIDER # Default provider
189
165
  update_data["model_name"] = model
190
166
  else:
191
167
  # Use current model if available
@@ -196,12 +172,12 @@ class AgentClient(BaseClient):
196
172
  update_data["model_name"] = current_agent.agent_config["lm_name"]
197
173
  else:
198
174
  # Default values
199
- update_data["provider"] = "openai"
200
- update_data["model_name"] = "gpt-4.1"
175
+ update_data["provider"] = DEFAULT_AGENT_PROVIDER
176
+ update_data["model_name"] = DEFAULT_MODEL
201
177
 
202
178
  # Handle tools and agents
203
179
  if "tools" in kwargs:
204
- tool_ids = self._extract_ids(kwargs["tools"])
180
+ tool_ids = extract_ids(kwargs["tools"])
205
181
  if tool_ids:
206
182
  update_data["tools"] = tool_ids
207
183
  elif current_agent.tools:
@@ -211,7 +187,7 @@ class AgentClient(BaseClient):
211
187
  ]
212
188
 
213
189
  if "agents" in kwargs:
214
- agent_ids = self._extract_ids(kwargs["agents"])
190
+ agent_ids = extract_ids(kwargs["agents"])
215
191
  if agent_ids:
216
192
  update_data["agents"] = agent_ids
217
193
  elif current_agent.agents:
@@ -239,30 +215,47 @@ class AgentClient(BaseClient):
239
215
  message: str,
240
216
  files: list[str | BinaryIO] | None = None,
241
217
  tty: bool = False,
242
- stream: bool = True,
243
218
  *,
244
219
  renderer: RichStreamRenderer | str | None = "auto",
245
- verbose: bool = False,
246
220
  **kwargs,
247
221
  ) -> str:
248
222
  """Run an agent with a message, streaming via a renderer."""
249
223
  # Prepare multipart data if files are provided
250
- form_data = None
251
- headers = {}
224
+ multipart_data = None
225
+ headers = None # None means "don't override client defaults"
226
+
227
+ if files:
228
+ multipart_data = prepare_multipart_data(message, files)
229
+ # Inject optional multipart extras expected by backend
230
+ if "chat_history" in kwargs and kwargs["chat_history"] is not None:
231
+ multipart_data.data["chat_history"] = kwargs["chat_history"]
232
+ if "pii_mapping" in kwargs and kwargs["pii_mapping"] is not None:
233
+ multipart_data.data["pii_mapping"] = kwargs["pii_mapping"]
234
+ headers = None # Let httpx set proper multipart boundaries
235
+
236
+ # When streaming, explicitly prefer SSE
237
+ headers = {**(headers or {}), "Accept": "text/event-stream"}
252
238
 
253
239
  if files:
254
- form_data = self._prepare_multipart_data(message, files)
255
- headers["Content-Type"] = "multipart/form-data"
256
240
  payload = None
241
+ # Use multipart data
242
+ data_payload = multipart_data.data
243
+ files_payload = multipart_data.files
257
244
  else:
258
245
  payload = {"input": message, **kwargs}
259
246
  if tty:
260
247
  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)
248
+ # Explicitly send stream intent both ways
249
+ payload["stream"] = True
250
+ data_payload = None
251
+ files_payload = None
252
+
253
+ # Choose renderer: use provided instance or create a default
254
+ if isinstance(renderer, RichStreamRenderer):
255
+ r = renderer
256
+ else:
257
+ # Default to a standard rich renderer
258
+ r = RichStreamRenderer(console=_Console())
266
259
 
267
260
  # Try to set some meta early; refine as we receive events
268
261
  meta = {
@@ -278,172 +271,117 @@ class AgentClient(BaseClient):
278
271
  started_monotonic = None
279
272
  finished_monotonic = None
280
273
 
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"
274
+ # MultipartData handles file cleanup automatically
275
+
276
+ try:
277
+ response = self.http_client.stream(
278
+ "POST",
279
+ f"/agents/{agent_id}/run",
280
+ json=payload,
281
+ data=data_payload,
282
+ files=files_payload,
283
+ headers=headers,
294
284
  )
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
285
 
312
- started_monotonic = monotonic()
286
+ with response as stream_response:
287
+ stream_response.raise_for_status()
313
288
 
314
- kind = (ev.get("metadata") or {}).get("kind")
289
+ # capture request id if provided
290
+ req_id = stream_response.headers.get(
291
+ "x-request-id"
292
+ ) or stream_response.headers.get("x-run-id")
293
+ if req_id:
294
+ meta["run_id"] = req_id
295
+ r.on_start(meta) # refresh header with run_id
315
296
 
316
- # Hide "artifact" chatter
317
- if kind == "artifact":
318
- continue
297
+ # Get agent run timeout for execution control
298
+ # Prefer CLI-provided timeout, otherwise use default
299
+ timeout_seconds = kwargs.get("timeout", DEFAULT_AGENT_RUN_TIMEOUT)
319
300
 
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
301
+ agent_name = kwargs.get("agent_name")
327
302
 
328
- # Step / tool events
329
- if kind == "agent_step":
330
- r.on_event(ev)
331
- continue
303
+ for event in iter_sse_events(
304
+ stream_response, timeout_seconds, agent_name
305
+ ):
306
+ try:
307
+ ev = json.loads(event["data"])
308
+ except json.JSONDecodeError:
309
+ logger.debug("Non-JSON SSE fragment skipped")
310
+ continue
332
311
 
333
- # Statuses: forward to renderer (it decides to collapse)
334
- if "status" in ev:
335
- r.on_event(ev)
336
- continue
312
+ # Start timer at first meaningful event
313
+ if started_monotonic is None and (
314
+ "content" in ev or "status" in ev or ev.get("metadata")
315
+ ):
316
+ started_monotonic = monotonic()
337
317
 
338
- # Usage/cost event (if your backend emits it)
339
- if kind == "usage":
340
- stats_usage.update(ev.get("usage") or {})
341
- continue
318
+ kind = (ev.get("metadata") or {}).get("kind")
342
319
 
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)
320
+ # Pass event to the renderer (always, don't filter)
321
+ r.on_event(ev)
351
322
 
352
- from time import monotonic
323
+ # Hide "artifact" chatter from content accumulation only
324
+ if kind == "artifact":
325
+ continue
326
+
327
+ # Accumulate assistant content, but do not print here
328
+ if "content" in ev and ev["content"]:
329
+ # Filter weird backend text like "Artifact received: ..."
330
+ if not ev["content"].startswith("Artifact received:"):
331
+ final_text = ev["content"] # replace with latest
332
+ continue
333
+
334
+ # Also treat final_response like content for CLI return value
335
+ if kind == "final_response" and ev.get("content"):
336
+ final_text = ev["content"] # ensure CLI non-empty
337
+ continue
338
+
339
+ # Usage/cost event (if your backend emits it)
340
+ if kind == "usage":
341
+ stats_usage.update(ev.get("usage") or {})
342
+ continue
343
+
344
+ # Model/run info (if emitted mid-stream)
345
+ if kind == "run_info":
346
+ if ev.get("model"):
347
+ meta["model"] = ev["model"]
348
+ r.on_start(meta)
349
+ if ev.get("run_id"):
350
+ meta["run_id"] = ev["run_id"]
351
+ r.on_start(meta)
353
352
 
354
353
  finished_monotonic = monotonic()
354
+ except KeyboardInterrupt:
355
+ try:
356
+ r.close()
357
+ finally:
358
+ raise
359
+ except Exception:
360
+ try:
361
+ r.close()
362
+ finally:
363
+ raise
364
+ finally:
365
+ # Ensure we close any opened file handles from multipart
366
+ if multipart_data:
367
+ multipart_data.close()
355
368
 
356
369
  # Finalize stats
357
- from glaip_sdk.utils.run_renderer import RunStats
358
-
359
370
  st = RunStats()
371
+ # Ensure monotonic order (avoid negative -0.0s)
372
+ if started_monotonic is None:
373
+ started_monotonic = finished_monotonic
374
+
360
375
  st.started_at = started_monotonic or st.started_at
361
376
  st.finished_at = finished_monotonic or st.started_at
362
377
  st.usage = stats_usage
363
378
 
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
379
+ # Prefer explicit content, otherwise fall back to what the renderer saw
380
+ if hasattr(r, "state") and hasattr(r.state, "buffer"):
381
+ rendered_text = "".join(r.state.buffer)
382
+ else:
383
+ rendered_text = ""
384
+ final_payload = final_text or rendered_text or "No response content received."
385
+
386
+ r.on_complete(st)
387
+ return final_payload