glaip-sdk 0.6.15b2__py3-none-any.whl → 0.6.16__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 (154) hide show
  1. glaip_sdk/agents/__init__.py +27 -0
  2. glaip_sdk/agents/base.py +1196 -0
  3. glaip_sdk/cli/__init__.py +9 -0
  4. glaip_sdk/cli/account_store.py +540 -0
  5. glaip_sdk/cli/agent_config.py +78 -0
  6. glaip_sdk/cli/auth.py +699 -0
  7. glaip_sdk/cli/commands/__init__.py +5 -0
  8. glaip_sdk/cli/commands/accounts.py +746 -0
  9. glaip_sdk/cli/commands/agents.py +1509 -0
  10. glaip_sdk/cli/commands/common_config.py +104 -0
  11. glaip_sdk/cli/commands/configure.py +896 -0
  12. glaip_sdk/cli/commands/mcps.py +1356 -0
  13. glaip_sdk/cli/commands/models.py +69 -0
  14. glaip_sdk/cli/commands/tools.py +576 -0
  15. glaip_sdk/cli/commands/transcripts.py +755 -0
  16. glaip_sdk/cli/commands/update.py +61 -0
  17. glaip_sdk/cli/config.py +95 -0
  18. glaip_sdk/cli/constants.py +38 -0
  19. glaip_sdk/cli/context.py +150 -0
  20. glaip_sdk/cli/core/__init__.py +79 -0
  21. glaip_sdk/cli/core/context.py +124 -0
  22. glaip_sdk/cli/core/output.py +851 -0
  23. glaip_sdk/cli/core/prompting.py +649 -0
  24. glaip_sdk/cli/core/rendering.py +187 -0
  25. glaip_sdk/cli/display.py +355 -0
  26. glaip_sdk/cli/hints.py +57 -0
  27. glaip_sdk/cli/io.py +112 -0
  28. glaip_sdk/cli/main.py +615 -0
  29. glaip_sdk/cli/masking.py +136 -0
  30. glaip_sdk/cli/mcp_validators.py +287 -0
  31. glaip_sdk/cli/pager.py +266 -0
  32. glaip_sdk/cli/parsers/__init__.py +7 -0
  33. glaip_sdk/cli/parsers/json_input.py +177 -0
  34. glaip_sdk/cli/resolution.py +67 -0
  35. glaip_sdk/cli/rich_helpers.py +27 -0
  36. glaip_sdk/cli/slash/__init__.py +15 -0
  37. glaip_sdk/cli/slash/accounts_controller.py +578 -0
  38. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  39. glaip_sdk/cli/slash/agent_session.py +285 -0
  40. glaip_sdk/cli/slash/prompt.py +256 -0
  41. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  42. glaip_sdk/cli/slash/session.py +1708 -0
  43. glaip_sdk/cli/slash/tui/__init__.py +9 -0
  44. glaip_sdk/cli/slash/tui/accounts_app.py +876 -0
  45. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  46. glaip_sdk/cli/slash/tui/loading.py +58 -0
  47. glaip_sdk/cli/slash/tui/remote_runs_app.py +628 -0
  48. glaip_sdk/cli/transcript/__init__.py +31 -0
  49. glaip_sdk/cli/transcript/cache.py +536 -0
  50. glaip_sdk/cli/transcript/capture.py +329 -0
  51. glaip_sdk/cli/transcript/export.py +38 -0
  52. glaip_sdk/cli/transcript/history.py +815 -0
  53. glaip_sdk/cli/transcript/launcher.py +77 -0
  54. glaip_sdk/cli/transcript/viewer.py +374 -0
  55. glaip_sdk/cli/update_notifier.py +290 -0
  56. glaip_sdk/cli/utils.py +263 -0
  57. glaip_sdk/cli/validators.py +238 -0
  58. glaip_sdk/client/__init__.py +11 -0
  59. glaip_sdk/client/_agent_payloads.py +520 -0
  60. glaip_sdk/client/agent_runs.py +147 -0
  61. glaip_sdk/client/agents.py +1335 -0
  62. glaip_sdk/client/base.py +502 -0
  63. glaip_sdk/client/main.py +249 -0
  64. glaip_sdk/client/mcps.py +370 -0
  65. glaip_sdk/client/run_rendering.py +700 -0
  66. glaip_sdk/client/shared.py +21 -0
  67. glaip_sdk/client/tools.py +661 -0
  68. glaip_sdk/client/validators.py +198 -0
  69. glaip_sdk/config/constants.py +52 -0
  70. glaip_sdk/mcps/__init__.py +21 -0
  71. glaip_sdk/mcps/base.py +345 -0
  72. glaip_sdk/models/__init__.py +90 -0
  73. glaip_sdk/models/agent.py +47 -0
  74. glaip_sdk/models/agent_runs.py +116 -0
  75. glaip_sdk/models/common.py +42 -0
  76. glaip_sdk/models/mcp.py +33 -0
  77. glaip_sdk/models/tool.py +33 -0
  78. glaip_sdk/payload_schemas/__init__.py +7 -0
  79. glaip_sdk/payload_schemas/agent.py +85 -0
  80. glaip_sdk/registry/__init__.py +55 -0
  81. glaip_sdk/registry/agent.py +164 -0
  82. glaip_sdk/registry/base.py +139 -0
  83. glaip_sdk/registry/mcp.py +253 -0
  84. glaip_sdk/registry/tool.py +232 -0
  85. glaip_sdk/runner/__init__.py +59 -0
  86. glaip_sdk/runner/base.py +84 -0
  87. glaip_sdk/runner/deps.py +112 -0
  88. glaip_sdk/runner/langgraph.py +782 -0
  89. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  90. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  91. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
  92. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +95 -0
  93. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  94. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  95. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +219 -0
  96. glaip_sdk/tools/__init__.py +22 -0
  97. glaip_sdk/tools/base.py +435 -0
  98. glaip_sdk/utils/__init__.py +86 -0
  99. glaip_sdk/utils/a2a/__init__.py +34 -0
  100. glaip_sdk/utils/a2a/event_processor.py +188 -0
  101. glaip_sdk/utils/agent_config.py +194 -0
  102. glaip_sdk/utils/bundler.py +267 -0
  103. glaip_sdk/utils/client.py +111 -0
  104. glaip_sdk/utils/client_utils.py +486 -0
  105. glaip_sdk/utils/datetime_helpers.py +58 -0
  106. glaip_sdk/utils/discovery.py +78 -0
  107. glaip_sdk/utils/display.py +135 -0
  108. glaip_sdk/utils/export.py +143 -0
  109. glaip_sdk/utils/general.py +61 -0
  110. glaip_sdk/utils/import_export.py +168 -0
  111. glaip_sdk/utils/import_resolver.py +492 -0
  112. glaip_sdk/utils/instructions.py +101 -0
  113. glaip_sdk/utils/rendering/__init__.py +115 -0
  114. glaip_sdk/utils/rendering/formatting.py +264 -0
  115. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  116. glaip_sdk/utils/rendering/layout/panels.py +156 -0
  117. glaip_sdk/utils/rendering/layout/progress.py +202 -0
  118. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  119. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  120. glaip_sdk/utils/rendering/models.py +85 -0
  121. glaip_sdk/utils/rendering/renderer/__init__.py +55 -0
  122. glaip_sdk/utils/rendering/renderer/base.py +1024 -0
  123. glaip_sdk/utils/rendering/renderer/config.py +27 -0
  124. glaip_sdk/utils/rendering/renderer/console.py +55 -0
  125. glaip_sdk/utils/rendering/renderer/debug.py +178 -0
  126. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  127. glaip_sdk/utils/rendering/renderer/stream.py +202 -0
  128. glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
  129. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  130. glaip_sdk/utils/rendering/renderer/toggle.py +182 -0
  131. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  132. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  133. glaip_sdk/utils/rendering/state.py +204 -0
  134. glaip_sdk/utils/rendering/step_tree_state.py +100 -0
  135. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  136. glaip_sdk/utils/rendering/steps/event_processor.py +778 -0
  137. glaip_sdk/utils/rendering/steps/format.py +176 -0
  138. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  139. glaip_sdk/utils/rendering/timing.py +36 -0
  140. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  141. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  142. glaip_sdk/utils/resource_refs.py +195 -0
  143. glaip_sdk/utils/run_renderer.py +41 -0
  144. glaip_sdk/utils/runtime_config.py +425 -0
  145. glaip_sdk/utils/serialization.py +424 -0
  146. glaip_sdk/utils/sync.py +142 -0
  147. glaip_sdk/utils/tool_detection.py +33 -0
  148. glaip_sdk/utils/validation.py +264 -0
  149. {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.16.dist-info}/METADATA +4 -5
  150. glaip_sdk-0.6.16.dist-info/RECORD +160 -0
  151. glaip_sdk-0.6.15b2.dist-info/RECORD +0 -12
  152. {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.16.dist-info}/WHEEL +0 -0
  153. {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.16.dist-info}/entry_points.txt +0 -0
  154. {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.16.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,111 @@
1
+ """Client singleton management for GLAIP SDK.
2
+
3
+ This module provides a singleton pattern for the GLAIP SDK client instance
4
+ used by the agents runtime. Uses a class-based singleton pattern consistent
5
+ with the registry implementations.
6
+
7
+ Thread Safety:
8
+ The singleton is created lazily on first access. In Python, the GIL ensures
9
+ that class attribute assignment is atomic, making this pattern safe for
10
+ multi-threaded access. For multiprocessing, each process gets its own
11
+ client instance (no shared state across processes).
12
+
13
+ Authors:
14
+ Christian Trisno Sen Long Chen (christian.t.s.l.chen@gdplabs.id)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dotenv import load_dotenv
20
+ from glaip_sdk.client import Client
21
+
22
+
23
+ class _ClientSingleton:
24
+ """Singleton holder for GLAIP SDK Client.
25
+
26
+ This class follows the same pattern as registry singletons
27
+ (_ToolRegistrySingleton, _MCPRegistrySingleton, _AgentRegistrySingleton).
28
+ """
29
+
30
+ _instance: Client | None = None
31
+
32
+ @classmethod
33
+ def get_instance(cls) -> Client:
34
+ """Get or create the singleton client instance.
35
+
36
+ Returns:
37
+ The singleton client instance.
38
+
39
+ Example:
40
+ >>> from glaip_sdk.utils.client import get_client
41
+ >>> client = get_client()
42
+ >>> agents = client.list_agents()
43
+ """
44
+ if cls._instance is None:
45
+ load_dotenv()
46
+ cls._instance = Client()
47
+ return cls._instance
48
+
49
+ @classmethod
50
+ def set_instance(cls, client: Client) -> None:
51
+ """Set the singleton client instance.
52
+
53
+ Useful for testing or when you need to configure the client manually.
54
+
55
+ Args:
56
+ client: The client instance to use.
57
+
58
+ Example:
59
+ >>> from glaip_sdk import Client
60
+ >>> from glaip_sdk.utils.client import set_client
61
+ >>> client = Client(api_key="my-key")
62
+ >>> set_client(client)
63
+ """
64
+ cls._instance = client
65
+
66
+ @classmethod
67
+ def reset(cls) -> None:
68
+ """Reset the singleton client instance.
69
+
70
+ Useful for testing to ensure a fresh client is created.
71
+ """
72
+ cls._instance = None
73
+
74
+
75
+ def get_client() -> Client:
76
+ """Get or create singleton client instance.
77
+
78
+ Returns:
79
+ The singleton client instance.
80
+
81
+ Example:
82
+ >>> from glaip_sdk.utils.client import get_client
83
+ >>> client = get_client()
84
+ >>> agents = client.list_agents()
85
+ """
86
+ return _ClientSingleton.get_instance()
87
+
88
+
89
+ def set_client(client: Client) -> None:
90
+ """Set the singleton client instance.
91
+
92
+ Useful for testing or when you need to configure the client manually.
93
+
94
+ Args:
95
+ client: The client instance to use.
96
+
97
+ Example:
98
+ >>> from glaip_sdk import Client
99
+ >>> from glaip_sdk.utils.client import set_client
100
+ >>> client = Client(api_key="my-key")
101
+ >>> set_client(client)
102
+ """
103
+ _ClientSingleton.set_instance(client)
104
+
105
+
106
+ def reset_client() -> None:
107
+ """Reset the singleton client instance.
108
+
109
+ Useful for testing to ensure a fresh client is created.
110
+ """
111
+ _ClientSingleton.reset()
@@ -0,0 +1,486 @@
1
+ #!/usr/bin/env python3
2
+ """Utility functions for AIP SDK clients.
3
+
4
+ This module contains generic utility functions that can be reused across
5
+ different client types (agents, tools, etc.).
6
+
7
+ Authors:
8
+ Raymond Christopher (raymond.christopher@gdplabs.id)
9
+ """
10
+
11
+ import logging
12
+ from collections.abc import AsyncGenerator, Iterator
13
+ from contextlib import ExitStack
14
+ from pathlib import Path
15
+ from typing import Any, BinaryIO, NoReturn
16
+
17
+ import httpx
18
+ from glaip_sdk.exceptions import AgentTimeoutError
19
+ from glaip_sdk.models import AgentResponse, MCPResponse, ToolResponse
20
+ from glaip_sdk.utils.resource_refs import extract_ids as extract_ids_new
21
+ from glaip_sdk.utils.resource_refs import find_by_name as find_by_name_new
22
+
23
+ # Set up module-level logger
24
+ logger = logging.getLogger("glaip_sdk.client_utils")
25
+
26
+
27
+ class MultipartData:
28
+ """Container for multipart form data with automatic file handle cleanup."""
29
+
30
+ def __init__(self, data: dict[str, Any], files: list[tuple[str, Any]]):
31
+ """Initialize multipart data container.
32
+
33
+ Args:
34
+ data: Form data dictionary
35
+ files: List of file tuples for multipart form
36
+ """
37
+ self.data = data
38
+ self.files = files
39
+ self._exit_stack = ExitStack()
40
+
41
+ def close(self) -> None:
42
+ """Close all opened file handles."""
43
+ self._exit_stack.close()
44
+
45
+ def __enter__(self) -> "MultipartData":
46
+ """Enter context manager.
47
+
48
+ Returns:
49
+ Self instance for context manager protocol
50
+ """
51
+ return self
52
+
53
+ def __exit__(
54
+ self,
55
+ _exc_type: type[BaseException] | None,
56
+ _exc_val: BaseException | None,
57
+ _exc_tb: Any,
58
+ ) -> None:
59
+ """Exit context manager and close all file handles.
60
+
61
+ Args:
62
+ _exc_type: Exception type (unused)
63
+ _exc_val: Exception value (unused)
64
+ _exc_tb: Exception traceback (unused)
65
+ """
66
+ self.close()
67
+
68
+
69
+ def extract_ids(items: list[str | Any] | None) -> list[str] | None:
70
+ """Extract IDs from a list of objects or strings.
71
+
72
+ Args:
73
+ items: List of items that may be strings, objects with .id, or other types
74
+
75
+ Returns:
76
+ List of extracted IDs, or None if items is empty/None
77
+
78
+ Note:
79
+ This function maintains backward compatibility by returning None for empty input.
80
+ New code should use glaip_sdk.utils.resource_refs.extract_ids which returns [].
81
+ """
82
+ if not items:
83
+ return None
84
+
85
+ result = extract_ids_new(items)
86
+ return result if result else None
87
+
88
+
89
+ def create_model_instances(data: list[dict] | None, model_class: type, client: Any) -> list[Any]:
90
+ """Create model instances from API data with client association.
91
+
92
+ This is a common pattern used across different clients (agents, tools, mcps)
93
+ to create model instances and associate them with the client.
94
+
95
+ For runtime classes (Agent, Tool, MCP) that have a from_response method,
96
+ this function will use the corresponding Response model to parse the API data
97
+ and then create the runtime instance using from_response.
98
+
99
+ Args:
100
+ data: List of dictionaries from API response
101
+ model_class: The model class to instantiate
102
+ client: The client instance to associate with models
103
+
104
+ Returns:
105
+ List of model instances with client association
106
+ """
107
+ if not data:
108
+ return []
109
+
110
+ # Check if the model_class has a from_response method (runtime class pattern)
111
+ if hasattr(model_class, "from_response"):
112
+ # Map runtime classes to their response models
113
+ response_model_map = {
114
+ "Agent": AgentResponse,
115
+ "Tool": ToolResponse,
116
+ "MCP": MCPResponse,
117
+ }
118
+
119
+ response_model = response_model_map.get(model_class.__name__)
120
+ if response_model:
121
+ instances = []
122
+ for item_data in data:
123
+ response = response_model(**item_data)
124
+ instance = model_class.from_response(response, client=client)
125
+ instances.append(instance)
126
+ return instances
127
+
128
+ # Fallback to direct instantiation for other classes
129
+ return [model_class(**item_data)._set_client(client) for item_data in data]
130
+
131
+
132
+ def find_by_name(items: list[Any], name: str, case_sensitive: bool = False) -> list[Any]:
133
+ """Filter items by name with optional case sensitivity.
134
+
135
+ This is a common pattern used across different clients for client-side
136
+ filtering when the backend doesn't support name query parameters.
137
+
138
+ Args:
139
+ items: List of items to filter
140
+ name: Name to search for
141
+ case_sensitive: Whether the search should be case sensitive
142
+
143
+ Returns:
144
+ Filtered list of items matching the name
145
+
146
+ Note:
147
+ This function now delegates to glaip_sdk.utils.resource_refs.find_by_name.
148
+ """
149
+ return find_by_name_new(items, name, case_sensitive)
150
+
151
+
152
+ def _handle_blank_line(
153
+ buf: list[str],
154
+ event_type: str | None,
155
+ event_id: str | None,
156
+ ) -> tuple[list[str], str | None, str | None, dict[str, Any] | None, bool]:
157
+ """Handle blank SSE lines by returning accumulated data if buffer exists."""
158
+ if buf:
159
+ data = "\n".join(buf)
160
+ return (
161
+ [],
162
+ None,
163
+ None,
164
+ {
165
+ "event": event_type or "message",
166
+ "id": event_id,
167
+ "data": data,
168
+ },
169
+ False,
170
+ )
171
+ return buf, event_type, event_id, None, False
172
+
173
+
174
+ def _handle_data_line(
175
+ line: str,
176
+ buf: list[str],
177
+ event_type: str | None,
178
+ event_id: str | None,
179
+ ) -> tuple[list[str], str | None, str | None, dict[str, Any] | None, bool]:
180
+ """Handle data: lines, including [DONE] sentinel marker."""
181
+ data_line = line[5:].lstrip()
182
+
183
+ if data_line.strip() == "[DONE]":
184
+ if buf:
185
+ data = "\n".join(buf)
186
+ return (
187
+ [],
188
+ None,
189
+ None,
190
+ {
191
+ "event": event_type or "message",
192
+ "id": event_id,
193
+ "data": data,
194
+ },
195
+ True,
196
+ )
197
+ return buf, event_type, event_id, None, True
198
+
199
+ buf.append(data_line)
200
+ return buf, event_type, event_id, None, False
201
+
202
+
203
+ def _handle_field_line(
204
+ line: str,
205
+ field_type: str,
206
+ current_value: str | None,
207
+ ) -> str | None:
208
+ """Handle event: or id: field lines."""
209
+ if field_type == "event":
210
+ return line[6:].strip() or None
211
+ elif field_type == "id":
212
+ return line[3:].strip() or None
213
+ return current_value
214
+
215
+
216
+ def _parse_sse_line(
217
+ line: str,
218
+ buf: list[str],
219
+ event_type: str | None = None,
220
+ event_id: str | None = None,
221
+ ) -> tuple[list[str], str | None, str | None, dict[str, Any] | None, bool]:
222
+ """Parse a single SSE line and return updated buffer and event metadata."""
223
+ # Normalize CRLF and treat whitespace-only as blank
224
+ line = line.rstrip("\r")
225
+
226
+ if not line.strip(): # blank line
227
+ return _handle_blank_line(buf, event_type, event_id)
228
+
229
+ if line.startswith(":"): # comment
230
+ return buf, event_type, event_id, None, False
231
+
232
+ if line.startswith("data:"):
233
+ return _handle_data_line(line, buf, event_type, event_id)
234
+
235
+ if line.startswith("event:"):
236
+ event_type = _handle_field_line(line, "event", event_type)
237
+ elif line.startswith("id:"):
238
+ event_id = _handle_field_line(line, "id", event_id)
239
+
240
+ return buf, event_type, event_id, None, False
241
+
242
+
243
+ def _handle_streaming_error(
244
+ e: Exception,
245
+ timeout_seconds: float | None = None,
246
+ agent_name: str | None = None,
247
+ ) -> NoReturn:
248
+ """Handle different types of streaming errors with appropriate logging and exceptions."""
249
+ if isinstance(e, httpx.ReadTimeout):
250
+ logger.error(f"Read timeout during streaming: {e}")
251
+ logger.error("This usually indicates the backend is taking too long to respond")
252
+ logger.error("Consider increasing the timeout value or checking backend performance")
253
+ raise AgentTimeoutError(timeout_seconds or 30.0, agent_name)
254
+
255
+ elif isinstance(e, httpx.TimeoutException):
256
+ logger.error(f"General timeout during streaming: {e}")
257
+ raise AgentTimeoutError(timeout_seconds or 30.0, agent_name)
258
+
259
+ elif isinstance(e, httpx.StreamClosed):
260
+ logger.error(f"Stream closed unexpectedly during streaming: {e}")
261
+ logger.error("This may indicate a backend issue or network problem")
262
+ logger.error("The response stream was closed before all data could be read")
263
+ raise
264
+
265
+ elif isinstance(e, httpx.ConnectError):
266
+ logger.error(f"Connection error during streaming: {e}")
267
+ logger.error("Check your network connection and backend availability")
268
+ raise
269
+
270
+ else:
271
+ logger.error(f"Unexpected error during streaming: {e}")
272
+ logger.error(f"Error type: {type(e).__name__}")
273
+ raise
274
+
275
+
276
+ def _process_sse_line(
277
+ line: str, buf: list[str], event_type: str | None, event_id: str | None
278
+ ) -> tuple[list[str], str | None, str | None, dict[str, Any] | None, bool]:
279
+ """Process a single SSE line and return updated state."""
280
+ result = _parse_sse_line(line, buf, event_type, event_id)
281
+ buf, event_type, event_id, event_data, completed = result
282
+ return buf, event_type, event_id, event_data, completed
283
+
284
+
285
+ def _yield_event_data(event_data: dict[str, Any] | None) -> Iterator[dict[str, Any]]:
286
+ """Yield event data if available."""
287
+ if event_data:
288
+ yield event_data
289
+
290
+
291
+ def _flush_remaining_buffer(buf: list[str], event_type: str | None, event_id: str | None) -> Iterator[dict[str, Any]]:
292
+ """Flush any remaining data in buffer."""
293
+ if buf:
294
+ yield {
295
+ "event": event_type or "message",
296
+ "id": event_id,
297
+ "data": "\n".join(buf),
298
+ }
299
+
300
+
301
+ def iter_sse_events(
302
+ response: httpx.Response,
303
+ timeout_seconds: float | None = None,
304
+ agent_name: str | None = None,
305
+ ) -> Iterator[dict[str, Any]]:
306
+ """Iterate over Server-Sent Events with proper parsing.
307
+
308
+ Args:
309
+ response: HTTP response object with streaming content
310
+ timeout_seconds: Timeout duration in seconds (for error messages)
311
+ agent_name: Agent name (for error messages)
312
+
313
+ Yields:
314
+ Dictionary with event data, type, and ID
315
+
316
+ Raises:
317
+ AgentTimeoutError: When agent execution times out
318
+ httpx.TimeoutException: When general timeout occurs
319
+ Exception: For other unexpected errors
320
+ """
321
+ buf = []
322
+ event_type = None
323
+ event_id = None
324
+
325
+ try:
326
+ for raw in response.iter_lines():
327
+ line = raw.decode("utf-8") if isinstance(raw, bytes) else raw
328
+ if line is None:
329
+ continue
330
+
331
+ buf, event_type, event_id, event_data, completed = _process_sse_line(line, buf, event_type, event_id)
332
+
333
+ yield from _yield_event_data(event_data)
334
+ if completed:
335
+ return
336
+
337
+ # Flush any remaining data
338
+ yield from _flush_remaining_buffer(buf, event_type, event_id)
339
+
340
+ except Exception as e:
341
+ _handle_streaming_error(e, timeout_seconds, agent_name)
342
+
343
+
344
+ async def aiter_sse_events(
345
+ response: httpx.Response, timeout_seconds: float = None, agent_name: str = None
346
+ ) -> AsyncGenerator[dict, None]:
347
+ """Async iterate over Server-Sent Events with proper parsing.
348
+
349
+ Args:
350
+ response: HTTP response object with streaming content
351
+ timeout_seconds: Timeout duration in seconds (for error messages)
352
+ agent_name: Agent name (for error messages)
353
+
354
+ Yields:
355
+ Dictionary with event data, type, and ID
356
+
357
+ Raises:
358
+ AgentTimeoutError: When agent execution times out
359
+ httpx.TimeoutException: When general timeout occurs
360
+ Exception: For other unexpected errors
361
+ """
362
+ buf = []
363
+ event_type = None
364
+ event_id = None
365
+
366
+ try:
367
+ async for raw in response.aiter_lines():
368
+ line = raw
369
+ if line is None:
370
+ continue
371
+
372
+ result = _parse_sse_line(line, buf, event_type, event_id)
373
+ buf, event_type, event_id, event_data, completed = result
374
+
375
+ if event_data:
376
+ yield event_data
377
+ if completed:
378
+ return
379
+
380
+ # Flush any remaining data
381
+ if buf:
382
+ yield {
383
+ "event": event_type or "message",
384
+ "id": event_id,
385
+ "data": "\n".join(buf),
386
+ }
387
+
388
+ except Exception as e:
389
+ _handle_streaming_error(e, timeout_seconds, agent_name)
390
+
391
+
392
+ def _create_form_data(message: str) -> dict[str, Any]:
393
+ """Create form data with message and stream flag."""
394
+ return {"input": message, "message": message, "stream": True}
395
+
396
+
397
+ def _prepare_file_entry(item: str | BinaryIO, stack: ExitStack) -> tuple[str, tuple[str, BinaryIO, str]]:
398
+ """Prepare a single file entry for multipart data."""
399
+ if isinstance(item, str):
400
+ return _prepare_path_entry(item, stack)
401
+ else:
402
+ return _prepare_stream_entry(item)
403
+
404
+
405
+ def _prepare_path_entry(path_str: str, stack: ExitStack) -> tuple[str, tuple[str, BinaryIO, str]]:
406
+ """Prepare a file path entry."""
407
+ file_path = Path(path_str)
408
+ if not file_path.exists():
409
+ raise FileNotFoundError(f"File not found: {path_str}")
410
+
411
+ handle = stack.enter_context(open(file_path, "rb"))
412
+ return (
413
+ "files",
414
+ (
415
+ file_path.name,
416
+ handle,
417
+ "application/octet-stream",
418
+ ),
419
+ )
420
+
421
+
422
+ def _prepare_stream_entry(
423
+ file_obj: BinaryIO,
424
+ ) -> tuple[str, tuple[str, BinaryIO, str]]:
425
+ """Prepare a file object entry."""
426
+ if not hasattr(file_obj, "read"):
427
+ raise ValueError(f"Invalid file object: {file_obj}")
428
+
429
+ raw_name = getattr(file_obj, "name", "file")
430
+ filename = Path(raw_name).name if raw_name else "file"
431
+
432
+ try:
433
+ if hasattr(file_obj, "seek"):
434
+ file_obj.seek(0)
435
+ except (OSError, ValueError):
436
+ pass
437
+
438
+ return (
439
+ "files",
440
+ (
441
+ filename,
442
+ file_obj,
443
+ "application/octet-stream",
444
+ ),
445
+ )
446
+
447
+
448
+ def add_kwargs_to_payload(payload: dict[str, Any], kwargs: dict[str, Any], excluded_keys: set[str]) -> None:
449
+ """Add kwargs to payload excluding specified keys.
450
+
451
+ Args:
452
+ payload: Payload dictionary to update.
453
+ kwargs: Keyword arguments to add.
454
+ excluded_keys: Keys to exclude from kwargs.
455
+ """
456
+ for key, value in kwargs.items():
457
+ if key not in excluded_keys:
458
+ payload[key] = value
459
+
460
+
461
+ def prepare_multipart_data(message: str, files: list[str | BinaryIO]) -> MultipartData:
462
+ """Prepare multipart form data for file uploads.
463
+
464
+ Args:
465
+ message: Text message to include with the upload
466
+ files: List of file paths or file-like objects
467
+
468
+ Returns:
469
+ MultipartData object with automatic file handle cleanup
470
+
471
+ Raises:
472
+ FileNotFoundError: When a file path doesn't exist
473
+ ValueError: When a file object is invalid
474
+ """
475
+ form_data = _create_form_data(message)
476
+ stack = ExitStack()
477
+ multipart_data = MultipartData(form_data, [])
478
+ multipart_data._exit_stack = stack
479
+
480
+ try:
481
+ file_entries = [_prepare_file_entry(item, stack) for item in files]
482
+ multipart_data.files = file_entries
483
+ return multipart_data
484
+ except Exception:
485
+ stack.close()
486
+ raise
@@ -0,0 +1,58 @@
1
+ """Shared datetime parsing helpers used across CLI and rendering modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from typing import Any
7
+
8
+ __all__ = ["coerce_datetime", "from_numeric_timestamp"]
9
+
10
+ _Z_SUFFIX = "+00:00"
11
+
12
+
13
+ def from_numeric_timestamp(raw_value: Any) -> datetime | None:
14
+ """Convert unix timestamp-like values to datetime with sanity checks."""
15
+ try:
16
+ candidate = float(raw_value)
17
+ except Exception:
18
+ return None
19
+
20
+ if candidate < 1_000_000_000:
21
+ return None
22
+
23
+ try:
24
+ return datetime.fromtimestamp(candidate, tz=timezone.utc)
25
+ except Exception:
26
+ return None
27
+
28
+
29
+ def _parse_iso(value: str | None) -> datetime | None:
30
+ """Parse ISO8601 strings while tolerating legacy 'Z' suffixes."""
31
+ if not value:
32
+ return None
33
+ try:
34
+ return datetime.fromisoformat(value.replace("Z", _Z_SUFFIX))
35
+ except Exception:
36
+ return None
37
+
38
+
39
+ def coerce_datetime(value: Any) -> datetime | None:
40
+ """Best-effort conversion of assorted timestamp inputs to aware UTC datetimes."""
41
+ if value is None:
42
+ return None
43
+
44
+ if isinstance(value, datetime):
45
+ dt = value
46
+ elif isinstance(value, (int, float)):
47
+ dt = from_numeric_timestamp(value)
48
+ elif isinstance(value, str):
49
+ dt = _parse_iso(value) or from_numeric_timestamp(value)
50
+ else:
51
+ return None
52
+
53
+ if dt is None:
54
+ return None
55
+
56
+ if dt.tzinfo is None:
57
+ dt = dt.replace(tzinfo=timezone.utc)
58
+ return dt.astimezone(timezone.utc)