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.
- glaip_sdk/__init__.py +2 -2
- glaip_sdk/_version.py +51 -0
- glaip_sdk/branding.py +145 -0
- glaip_sdk/cli/commands/agents.py +876 -166
- glaip_sdk/cli/commands/configure.py +46 -104
- glaip_sdk/cli/commands/init.py +43 -118
- glaip_sdk/cli/commands/mcps.py +86 -161
- glaip_sdk/cli/commands/tools.py +196 -57
- glaip_sdk/cli/main.py +43 -29
- glaip_sdk/cli/utils.py +258 -27
- glaip_sdk/client/__init__.py +54 -2
- glaip_sdk/client/agents.py +196 -237
- glaip_sdk/client/base.py +62 -2
- glaip_sdk/client/mcps.py +63 -20
- glaip_sdk/client/tools.py +236 -81
- glaip_sdk/config/constants.py +10 -3
- glaip_sdk/exceptions.py +13 -0
- glaip_sdk/models.py +21 -5
- glaip_sdk/utils/__init__.py +116 -18
- glaip_sdk/utils/client_utils.py +284 -0
- glaip_sdk/utils/rendering/__init__.py +1 -0
- glaip_sdk/utils/rendering/formatting.py +211 -0
- glaip_sdk/utils/rendering/models.py +53 -0
- glaip_sdk/utils/rendering/renderer/__init__.py +38 -0
- glaip_sdk/utils/rendering/renderer/base.py +827 -0
- glaip_sdk/utils/rendering/renderer/config.py +33 -0
- glaip_sdk/utils/rendering/renderer/console.py +54 -0
- glaip_sdk/utils/rendering/renderer/debug.py +82 -0
- glaip_sdk/utils/rendering/renderer/panels.py +123 -0
- glaip_sdk/utils/rendering/renderer/progress.py +118 -0
- glaip_sdk/utils/rendering/renderer/stream.py +198 -0
- glaip_sdk/utils/rendering/steps.py +168 -0
- glaip_sdk/utils/run_renderer.py +22 -1086
- {glaip_sdk-0.0.2.dist-info → glaip_sdk-0.0.4.dist-info}/METADATA +8 -36
- glaip_sdk-0.0.4.dist-info/RECORD +41 -0
- glaip_sdk/cli/config.py +0 -592
- glaip_sdk/utils.py +0 -167
- glaip_sdk-0.0.2.dist-info/RECORD +0 -28
- {glaip_sdk-0.0.2.dist-info → glaip_sdk-0.0.4.dist-info}/WHEEL +0 -0
- {glaip_sdk-0.0.2.dist-info → glaip_sdk-0.0.4.dist-info}/entry_points.txt +0 -0
glaip_sdk/client/agents.py
CHANGED
|
@@ -7,46 +7,36 @@ Authors:
|
|
|
7
7
|
|
|
8
8
|
import json
|
|
9
9
|
import logging
|
|
10
|
-
import
|
|
10
|
+
from time import monotonic
|
|
11
11
|
from typing import Any, BinaryIO
|
|
12
12
|
|
|
13
|
-
import
|
|
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.
|
|
19
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
78
|
+
model: str = DEFAULT_MODEL,
|
|
103
79
|
tools: list[str | Any] | None = None,
|
|
104
80
|
agents: list[str | Any] | None = None,
|
|
105
|
-
timeout: int =
|
|
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":
|
|
124
|
-
"framework":
|
|
125
|
-
"version":
|
|
126
|
-
"provider":
|
|
127
|
-
"model_name": model or
|
|
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 =
|
|
140
|
-
agent_ids =
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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":
|
|
182
|
-
"framework":
|
|
183
|
-
"version":
|
|
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"] =
|
|
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"] =
|
|
200
|
-
update_data["model_name"] =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
307
|
+
with response as stream_response:
|
|
308
|
+
stream_response.raise_for_status()
|
|
313
309
|
|
|
314
|
-
|
|
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
|
-
#
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
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
|
-
|
|
344
|
-
|
|
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
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
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=
|
|
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}")
|