glaip-sdk 0.0.1b5__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.
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env python3
2
+ """Main client for AIP SDK.
3
+
4
+ Authors:
5
+ Raymond Christopher (raymond.christopher@gdplabs.id)
6
+ """
7
+
8
+ from glaip_sdk.client.agents import AgentClient
9
+ from glaip_sdk.client.base import BaseClient
10
+ from glaip_sdk.client.mcps import MCPClient
11
+ from glaip_sdk.client.tools import ToolClient
12
+ from glaip_sdk.models import MCP, Agent, Tool
13
+
14
+
15
+ class Client(BaseClient):
16
+ """Main client that composes all specialized clients and shares one HTTP session."""
17
+
18
+ def __init__(self, **kwargs):
19
+ super().__init__(**kwargs)
20
+ # Share the single httpx.Client + config with sub-clients
21
+ shared_config = {
22
+ "parent_client": self,
23
+ "api_url": self.api_url,
24
+ "api_key": self.api_key,
25
+ "timeout": self._timeout,
26
+ }
27
+ self.agents = AgentClient(**shared_config)
28
+ self.tools = ToolClient(**shared_config)
29
+ self.mcps = MCPClient(**shared_config)
30
+
31
+ # ---- Agents
32
+ def list_agents(self) -> list[Agent]:
33
+ agents = self.agents.list_agents()
34
+ for agent in agents:
35
+ agent._set_client(self)
36
+ return agents
37
+
38
+ def get_agent_by_id(self, agent_id: str) -> Agent:
39
+ agent = self.agents.get_agent_by_id(agent_id)
40
+ agent._set_client(self)
41
+ return agent
42
+
43
+ def find_agents(self, name: str | None = None) -> list[Agent]:
44
+ agents = self.agents.find_agents(name)
45
+ for agent in agents:
46
+ agent._set_client(self)
47
+ return agents
48
+
49
+ def create_agent(
50
+ self,
51
+ name: str | None = None,
52
+ model: str | None = None,
53
+ instruction: str | None = None,
54
+ tools: list[str | Tool] | None = None,
55
+ agents: list[str | Agent] | None = None,
56
+ timeout: int = 300,
57
+ **kwargs,
58
+ ) -> Agent:
59
+ agent = self.agents.create_agent(
60
+ name=name,
61
+ model=model,
62
+ instruction=instruction,
63
+ tools=tools,
64
+ agents=agents,
65
+ timeout=timeout,
66
+ **kwargs,
67
+ )
68
+ agent._set_client(self)
69
+ return agent
70
+
71
+ def delete_agent(self, agent_id: str) -> None:
72
+ """Delete an agent by ID."""
73
+ return self.agents.delete_agent(agent_id)
74
+
75
+ def update_agent(
76
+ self, agent_id: str, update_data: dict | None = None, **kwargs
77
+ ) -> Agent:
78
+ """Update an agent by ID."""
79
+ if update_data:
80
+ kwargs.update(update_data)
81
+ return self.agents.update_agent(agent_id, **kwargs)
82
+
83
+ # ---- Tools
84
+ def list_tools(self) -> list[Tool]:
85
+ tools = self.tools.list_tools()
86
+ for tool in tools:
87
+ tool._set_client(self)
88
+ return tools
89
+
90
+ def get_tool_by_id(self, tool_id: str) -> Tool:
91
+ tool = self.tools.get_tool_by_id(tool_id)
92
+ tool._set_client(self)
93
+ return tool
94
+
95
+ def find_tools(self, name: str | None = None) -> list[Tool]:
96
+ tools = self.tools.find_tools(name)
97
+ for tool in tools:
98
+ tool._set_client(self)
99
+ return tools
100
+
101
+ def create_tool(self, **kwargs) -> Tool:
102
+ tool = self.tools.create_tool(**kwargs)
103
+ tool._set_client(self)
104
+ return tool
105
+
106
+ def create_tool_from_code(
107
+ self, name: str, code: str, framework: str = "langchain"
108
+ ) -> Tool:
109
+ """Create a new tool plugin from code string."""
110
+ tool = self.tools.create_tool_from_code(name, code, framework)
111
+ tool._set_client(self)
112
+ return tool
113
+
114
+ def update_tool(self, tool_id: str, **kwargs) -> Tool:
115
+ """Update an existing tool."""
116
+ return self.tools.update_tool(tool_id, **kwargs)
117
+
118
+ def delete_tool(self, tool_id: str) -> None:
119
+ """Delete a tool by ID."""
120
+ return self.tools.delete_tool(tool_id)
121
+
122
+ # ---- MCPs
123
+ def list_mcps(self) -> list[MCP]:
124
+ mcps = self.mcps.list_mcps()
125
+ for mcp in mcps:
126
+ mcp._set_client(self)
127
+ return mcps
128
+
129
+ def get_mcp_by_id(self, mcp_id: str) -> MCP:
130
+ mcp = self.mcps.get_mcp_by_id(mcp_id)
131
+ mcp._set_client(self)
132
+ return mcp
133
+
134
+ def find_mcps(self, name: str | None = None) -> list[MCP]:
135
+ mcps = self.mcps.find_mcps(name)
136
+ for mcp in mcps:
137
+ mcp._set_client(self)
138
+ return mcps
139
+
140
+ def create_mcp(self, **kwargs) -> MCP:
141
+ mcp = self.mcps.create_mcp(**kwargs)
142
+ mcp._set_client(self)
143
+ return mcp
144
+
145
+ def delete_mcp(self, mcp_id: str) -> None:
146
+ """Delete an MCP by ID."""
147
+ return self.mcps.delete_mcp(mcp_id)
148
+
149
+ def update_mcp(self, mcp_id: str, **kwargs) -> MCP:
150
+ """Update an MCP by ID."""
151
+ return self.mcps.update_mcp(mcp_id, **kwargs)
152
+
153
+ def run_agent(self, agent_id: str, message: str, **kwargs) -> str:
154
+ """Run an agent with a message."""
155
+ return self.agents.run_agent(agent_id, message, **kwargs)
156
+
157
+ # ---- Language Models
158
+ def list_language_models(self) -> list[dict]:
159
+ """List available language models."""
160
+ data = self._request("GET", "/language-models")
161
+ return data or []
162
+
163
+ # ---- Aliases (back-compat)
164
+ def get_agent(self, agent_id: str) -> Agent:
165
+ return self.get_agent_by_id(agent_id)
166
+
167
+ def get_tool(self, tool_id: str) -> Tool:
168
+ return self.get_tool_by_id(tool_id)
169
+
170
+ def get_mcp(self, mcp_id: str) -> MCP:
171
+ return self.get_mcp_by_id(mcp_id)
172
+
173
+ # ---- Health
174
+ def ping(self) -> bool:
175
+ try:
176
+ self._request("GET", "/health-check")
177
+ return True
178
+ except Exception:
179
+ return False
@@ -0,0 +1,441 @@
1
+ #!/usr/bin/env python3
2
+ """Agent client for AIP SDK.
3
+
4
+ Authors:
5
+ Raymond Christopher (raymond.christopher@gdplabs.id)
6
+ """
7
+
8
+ import json
9
+ import logging
10
+ import sys
11
+ from typing import Any, BinaryIO
12
+
13
+ import httpx
14
+ from rich.console import Console
15
+
16
+ from glaip_sdk.client.base import BaseClient
17
+ from glaip_sdk.models import Agent
18
+ from glaip_sdk.utils.run_renderer import (
19
+ RichStreamRenderer,
20
+ )
21
+
22
+ # Set up module-level logger
23
+ logger = logging.getLogger("glaip_sdk.agents")
24
+
25
+
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
+ class AgentClient(BaseClient):
51
+ """Client for agent operations."""
52
+
53
+ def __init__(self, *, parent_client: BaseClient | None = None, **kwargs):
54
+ """Initialize the agent client.
55
+
56
+ Args:
57
+ parent_client: Parent client to adopt session/config from
58
+ **kwargs: Additional arguments for standalone initialization
59
+ """
60
+ super().__init__(parent_client=parent_client, **kwargs)
61
+
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
+ def list_agents(self) -> list[Agent]:
80
+ """List all agents."""
81
+ data = self._request("GET", "/agents/")
82
+ return [Agent(**agent_data)._set_client(self) for agent_data in (data or [])]
83
+
84
+ def get_agent_by_id(self, agent_id: str) -> Agent:
85
+ """Get agent by ID."""
86
+ data = self._request("GET", f"/agents/{agent_id}")
87
+ return Agent(**data)._set_client(self)
88
+
89
+ def find_agents(self, name: str | None = None) -> list[Agent]:
90
+ """Find agents by name."""
91
+ params = {}
92
+ if name:
93
+ params["name"] = name
94
+
95
+ data = self._request("GET", "/agents/", params=params)
96
+ return [Agent(**agent_data)._set_client(self) for agent_data in (data or [])]
97
+
98
+ def create_agent(
99
+ self,
100
+ name: str,
101
+ instruction: str,
102
+ model: str = "gpt-4.1",
103
+ tools: list[str | Any] | None = None,
104
+ agents: list[str | Any] | None = None,
105
+ timeout: int = 300,
106
+ **kwargs,
107
+ ) -> "Agent":
108
+ """Create a new agent."""
109
+ # Client-side validation
110
+ if not name or not name.strip():
111
+ raise ValueError("Agent name cannot be empty or whitespace")
112
+
113
+ if not instruction or not instruction.strip():
114
+ raise ValueError("Agent instruction cannot be empty or whitespace")
115
+
116
+ if len(instruction.strip()) < 10:
117
+ raise ValueError("Agent instruction must be at least 10 characters long")
118
+
119
+ # Prepare the creation payload
120
+ payload = {
121
+ "name": name.strip(),
122
+ "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
128
+ }
129
+
130
+ # Extract IDs from tool and agent objects
131
+ tool_ids = self._extract_ids(tools)
132
+ agent_ids = self._extract_ids(agents)
133
+
134
+ # Add tools and agents if provided
135
+ if tool_ids:
136
+ payload["tools"] = tool_ids
137
+ if agent_ids:
138
+ payload["agents"] = agent_ids
139
+
140
+ # Add any additional kwargs
141
+ payload.update(kwargs)
142
+
143
+ # Create the agent
144
+ data = self._request("POST", "/agents/", json=payload)
145
+
146
+ # The backend only returns the ID, so we need to fetch the full agent details
147
+ agent_id = data.get("id")
148
+ if not agent_id:
149
+ raise ValueError("Backend did not return agent ID")
150
+
151
+ # Fetch the full agent details
152
+ full_agent_data = self._request("GET", f"/agents/{agent_id}")
153
+ return Agent(**full_agent_data)._set_client(self)
154
+
155
+ def update_agent(
156
+ self,
157
+ agent_id: str,
158
+ name: str | None = None,
159
+ instruction: str | None = None,
160
+ model: str | None = None,
161
+ **kwargs,
162
+ ) -> "Agent":
163
+ """Update an existing agent."""
164
+ # First, get the current agent data
165
+ current_agent = self.get_agent_by_id(agent_id)
166
+
167
+ # Prepare the update payload with current values as defaults
168
+ update_data = {
169
+ "name": name if name is not None else current_agent.name,
170
+ "instruction": instruction
171
+ if instruction is not None
172
+ else current_agent.instruction,
173
+ "type": "config", # Required by backend
174
+ "framework": "langchain", # Required by backend
175
+ "version": "1.0", # Required by backend
176
+ }
177
+
178
+ # Handle model specification
179
+ if model is not None:
180
+ update_data["provider"] = "openai" # Default provider
181
+ update_data["model_name"] = model
182
+ else:
183
+ # Use current model if available
184
+ if hasattr(current_agent, "agent_config") and current_agent.agent_config:
185
+ if "lm_provider" in current_agent.agent_config:
186
+ update_data["provider"] = current_agent.agent_config["lm_provider"]
187
+ if "lm_name" in current_agent.agent_config:
188
+ update_data["model_name"] = current_agent.agent_config["lm_name"]
189
+ else:
190
+ # Default values
191
+ update_data["provider"] = "openai"
192
+ update_data["model_name"] = "gpt-4.1"
193
+
194
+ # Handle tools and agents
195
+ if "tools" in kwargs:
196
+ tool_ids = self._extract_ids(kwargs["tools"])
197
+ if tool_ids:
198
+ update_data["tools"] = tool_ids
199
+ elif current_agent.tools:
200
+ update_data["tools"] = [
201
+ tool["id"] if isinstance(tool, dict) else tool
202
+ for tool in current_agent.tools
203
+ ]
204
+
205
+ if "agents" in kwargs:
206
+ agent_ids = self._extract_ids(kwargs["agents"])
207
+ if agent_ids:
208
+ update_data["agents"] = agent_ids
209
+ elif current_agent.agents:
210
+ update_data["agents"] = [
211
+ agent["id"] if isinstance(agent, dict) else agent
212
+ for agent in current_agent.agents
213
+ ]
214
+
215
+ # Add any other kwargs
216
+ for key, value in kwargs.items():
217
+ if key not in ["tools", "agents"]:
218
+ update_data[key] = value
219
+
220
+ # Send the complete payload
221
+ data = self._request("PUT", f"/agents/{agent_id}", json=update_data)
222
+ return Agent(**data)._set_client(self)
223
+
224
+ def delete_agent(self, agent_id: str) -> None:
225
+ """Delete an agent."""
226
+ self._request("DELETE", f"/agents/{agent_id}")
227
+
228
+ def run_agent(
229
+ self,
230
+ agent_id: str,
231
+ message: str,
232
+ files: list[str | BinaryIO] | None = None,
233
+ tty: bool = False,
234
+ stream: bool = True,
235
+ *,
236
+ renderer: RichStreamRenderer | str | None = "auto",
237
+ verbose: bool = False,
238
+ **kwargs,
239
+ ) -> str:
240
+ """Run an agent with a message, streaming via a renderer."""
241
+ # Prepare multipart data if files are provided
242
+ form_data = None
243
+ headers = {}
244
+
245
+ if files:
246
+ form_data = self._prepare_multipart_data(message, files)
247
+ headers["Content-Type"] = "multipart/form-data"
248
+ payload = None
249
+ else:
250
+ payload = {"input": message, **kwargs}
251
+ if tty:
252
+ payload["tty"] = True
253
+ if not stream:
254
+ payload["stream"] = False
255
+
256
+ # Choose renderer (even if stream=False, we can still format final)
257
+ r = _select_renderer(renderer, verbose=verbose)
258
+
259
+ # Try to set some meta early; refine as we receive events
260
+ meta = {
261
+ "agent_name": kwargs.get("agent_name", agent_id),
262
+ "model": kwargs.get("model"),
263
+ "run_id": None,
264
+ "input_message": message, # Add the original query for context
265
+ }
266
+ r.on_start(meta)
267
+
268
+ final_text = ""
269
+ stats_usage = {}
270
+ started_monotonic = None
271
+ finished_monotonic = None
272
+
273
+ with self.http_client.stream(
274
+ "POST",
275
+ f"/agents/{agent_id}/run",
276
+ json=payload if not files else None,
277
+ data=form_data.get("data") if files else None,
278
+ files=form_data.get("files") if files else None,
279
+ headers=headers,
280
+ ) as response:
281
+ response.raise_for_status()
282
+
283
+ # capture request id if provided
284
+ req_id = response.headers.get("x-request-id") or response.headers.get(
285
+ "x-run-id"
286
+ )
287
+ if req_id:
288
+ meta["run_id"] = req_id
289
+ r.on_start(meta) # refresh header with run_id
290
+
291
+ for event in self._iter_sse_events(response):
292
+ try:
293
+ ev = json.loads(event["data"])
294
+ except json.JSONDecodeError:
295
+ logger.debug("Non-JSON SSE fragment skipped")
296
+ continue
297
+
298
+ # Start timer at first meaningful event
299
+ if started_monotonic is None and (
300
+ "content" in ev or "status" in ev or ev.get("metadata")
301
+ ):
302
+ from time import monotonic
303
+
304
+ started_monotonic = monotonic()
305
+
306
+ kind = (ev.get("metadata") or {}).get("kind")
307
+
308
+ # Hide "artifact" chatter
309
+ if kind == "artifact":
310
+ continue
311
+
312
+ # Accumulate assistant content, but do not print here
313
+ if "content" in ev and ev["content"]:
314
+ # Filter weird backend text like "Artifact received: ..."
315
+ if not ev["content"].startswith("Artifact received:"):
316
+ final_text = ev["content"] # replace with latest
317
+ r.on_event(ev)
318
+ continue
319
+
320
+ # Step / tool events
321
+ if kind == "agent_step":
322
+ r.on_event(ev)
323
+ continue
324
+
325
+ # Statuses: forward to renderer (it decides to collapse)
326
+ if "status" in ev:
327
+ r.on_event(ev)
328
+ continue
329
+
330
+ # Usage/cost event (if your backend emits it)
331
+ if kind == "usage":
332
+ stats_usage.update(ev.get("usage") or {})
333
+ continue
334
+
335
+ # Model/run info (if emitted mid-stream)
336
+ if kind == "run_info":
337
+ if ev.get("model"):
338
+ meta["model"] = ev["model"]
339
+ r.on_start(meta)
340
+ if ev.get("run_id"):
341
+ meta["run_id"] = ev["run_id"]
342
+ r.on_start(meta)
343
+
344
+ from time import monotonic
345
+
346
+ finished_monotonic = monotonic()
347
+
348
+ # Finalize stats
349
+ from glaip_sdk.utils.run_renderer import RunStats
350
+
351
+ st = RunStats()
352
+ st.started_at = started_monotonic or st.started_at
353
+ st.finished_at = finished_monotonic or st.started_at
354
+ st.usage = stats_usage
355
+
356
+ r.on_complete(final_text or "No response content received.", st)
357
+ return final_text or "No response content received."
358
+
359
+ def _iter_sse_events(self, response: httpx.Response):
360
+ """Iterate over Server-Sent Events with proper parsing."""
361
+ buf = []
362
+ event_type = None
363
+ event_id = None
364
+
365
+ for raw in response.iter_lines():
366
+ line = raw.decode("utf-8") if isinstance(raw, bytes) else raw
367
+
368
+ if line == "":
369
+ if buf:
370
+ data = "\n".join(buf)
371
+ yield {
372
+ "event": event_type or "message",
373
+ "id": event_id,
374
+ "data": data,
375
+ }
376
+ buf, event_type, event_id = [], None, None
377
+ continue
378
+
379
+ if line.startswith(":"): # comment
380
+ continue
381
+ if line.startswith("data:"):
382
+ buf.append(line[5:].lstrip())
383
+ elif line.startswith("event:"):
384
+ event_type = line[6:].strip() or None
385
+ elif line.startswith("id:"):
386
+ event_id = line[3:].strip() or None
387
+
388
+ # Flush any remaining data
389
+ if buf:
390
+ yield {
391
+ "event": event_type or "message",
392
+ "id": event_id,
393
+ "data": "\n".join(buf),
394
+ }
395
+
396
+ def _prepare_multipart_data(
397
+ self, message: str, files: list[str | BinaryIO]
398
+ ) -> dict[str, Any]:
399
+ """Prepare multipart form data for file uploads."""
400
+ from pathlib import Path
401
+
402
+ form_data = {"data": {"message": message}}
403
+ file_list = []
404
+
405
+ for file_item in files:
406
+ if isinstance(file_item, str):
407
+ # File path - let httpx stream the file handle
408
+ file_path = Path(file_item)
409
+ if not file_path.exists():
410
+ raise FileNotFoundError(f"File not found: {file_item}")
411
+
412
+ file_list.append(
413
+ (
414
+ "files",
415
+ (
416
+ file_path.name,
417
+ open(file_path, "rb"),
418
+ "application/octet-stream",
419
+ ),
420
+ )
421
+ )
422
+ else:
423
+ # File-like object
424
+ if hasattr(file_item, "name"):
425
+ filename = getattr(file_item, "name", "file")
426
+ else:
427
+ filename = "file"
428
+
429
+ if hasattr(file_item, "read"):
430
+ # For file-like objects, we need to read them since httpx expects bytes
431
+ file_content = file_item.read()
432
+ file_list.append(
433
+ ("files", (filename, file_content, "application/octet-stream"))
434
+ )
435
+ else:
436
+ raise ValueError(f"Invalid file object: {file_item}")
437
+
438
+ if file_list:
439
+ form_data["files"] = file_list
440
+
441
+ return form_data