inferenceport-cli 1.0.0__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.
ipx/commands/agent.py ADDED
@@ -0,0 +1,459 @@
1
+ """ipx agent - InferencePort managed agents: a backing model + system prompt (+ MCP servers) callable as `agent/<user>/<id>`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from typing import Any, Dict, List, Optional, Tuple
8
+
9
+ import click
10
+ from rich.text import Text
11
+
12
+ from ipx import chat as chatmod
13
+ from ipx import cloud, ui
14
+ from ipx.app import App, pass_app
15
+ from ipx.clickx import IpxGroup
16
+ from ipx.cloud import P2G
17
+ from ipx.errors import IpxError
18
+
19
+ ROLES = ["user", "editor", "inviter", "administrator"]
20
+
21
+
22
+ # --------------------------------------------------------------------------- helpers
23
+
24
+
25
+ def rows_of(d: Any, key: str = "data") -> List[Dict[str, Any]]:
26
+ rows = d.get(key) if isinstance(d, dict) else d
27
+ return [x for x in (rows or []) if isinstance(x, dict)]
28
+
29
+
30
+ def split_ref(app: App, ref: str) -> Tuple[str, str]:
31
+ """`user/agent`, `agent/user/agent` or a bare agent id (yours) -> (username, agent_id)."""
32
+ parts = [p for p in ref.strip().split("/") if p]
33
+ if parts and parts[0] == "agent":
34
+ parts = parts[1:]
35
+ if len(parts) == 2:
36
+ return parts[0].lower(), parts[1].lower()
37
+ if len(parts) == 1:
38
+ d = app.api.get("/v1/agents/profile", org=False) or {}
39
+ if not d.get("username"):
40
+ raise IpxError("You have no agent username yet.", "Set one with `ipx agent profile set <username>`, or use the full user/agent reference.")
41
+ return d["username"], parts[0].lower()
42
+ raise IpxError(f"'{ref}' is not an agent reference.", "Use <username>/<agent-id>, e.g. `acme-bot/support`.")
43
+
44
+
45
+ def path(app: App, ref: str, suffix: str = "") -> str:
46
+ user, aid = split_ref(app, ref)
47
+ return f"/v1/agents/{user}/{aid}{suffix}"
48
+
49
+
50
+ def api(app: App, method: str, p: str, body: Any = None, **kw: Any) -> Any:
51
+ kw.setdefault("org", False) # agents belong to people; only chat bills an organization
52
+ r = app.api.request(method, p, json=body, **kw)
53
+ return r.json() if r.body else {}
54
+
55
+
56
+ def read_text(value: Optional[str], file: Optional[str]) -> Optional[str]:
57
+ if file:
58
+ return sys.stdin.read() if file == "-" else open(file, "r", encoding="utf-8").read()
59
+ return value
60
+
61
+
62
+ def parse_mcp(pairs: Tuple[str, ...], files: Tuple[str, ...]) -> Optional[List[Dict[str, Any]]]:
63
+ out: List[Dict[str, Any]] = []
64
+ for f in files:
65
+ try:
66
+ val = json.loads(sys.stdin.read() if f == "-" else open(f, "r", encoding="utf-8").read())
67
+ except ValueError as e:
68
+ raise IpxError(f"Invalid JSON in {f}: {e}")
69
+ out.extend(val if isinstance(val, list) else [val])
70
+ for p in pairs:
71
+ name, sep, url = p.partition("=")
72
+ if not sep or not name.strip() or not url.strip():
73
+ raise IpxError(f"Bad --mcp value '{p}'.", "Use NAME=URL, e.g. --mcp docs=https://mcp.example.com/mcp. For auth, pass a JSON file with --mcp-file.")
74
+ out.append({"name": name.strip(), "url": url.strip(), "transport": "auto", "auth_type": "none"})
75
+ return out or None
76
+
77
+
78
+ def vis_text(v: Any) -> Text:
79
+ return Text(str(v or "—"), style="ok" if v == "public" else "muted")
80
+
81
+
82
+ def show_agent(a: Dict[str, Any]) -> None:
83
+ rows = [
84
+ ("Model id", a.get("id")), ("Name", a.get("name")), ("Description", a.get("description")),
85
+ ("Backing model", a.get("backing_model")), ("Visibility", vis_text(a.get("visibility"))),
86
+ ("Registry", a.get("registry")), ("Billing", a.get("payer_label") or a.get("payer")),
87
+ ("Your role", a.get("role")), ("MCP servers", ", ".join(f"{s.get('name')} ({s.get('url')})" for s in a.get("mcp_servers") or []) or None),
88
+ ("Updated", str(a.get("updated_at") or "")[:16].replace("T", " ") or None),
89
+ ]
90
+ ui.console.print(ui.kv_panel(str(a.get("name") or a.get("id") or "Agent"), [r for r in rows if r[1] not in (None, "")]))
91
+ if a.get("system_prompt"):
92
+ ui.console.print(ui.kv_panel("System prompt", [("", a["system_prompt"])]))
93
+ members = a.get("members") or []
94
+ if members:
95
+ ui.console.print(ui.make_table(["User", "Email", "Role"], [(m.get("user_id"), m.get("email"), m.get("role")) for m in members], title="Members"))
96
+ for inv in a.get("invites") or []:
97
+ ui.console.print(f" pending invite: {inv.get('email') or inv.get('invitee_email') or inv.get('user_id')} as {inv.get('role')} · {inv.get('invite_url')}")
98
+
99
+
100
+ def agent_table(rows: List[Dict[str, Any]], title: str, owner: bool = True) -> None:
101
+ ui.console.print(ui.make_table(
102
+ ["Agent", "Name", "Backing model", "Visibility", "Billing"] + (["Role"] if owner else ["Owner"]),
103
+ [(str(x.get("id") or "").removeprefix("agent/"), x.get("name"), x.get("backing_model"), vis_text(x.get("visibility")), "owner pays" if x.get("payer") == "owner" else "caller pays",
104
+ (x.get("role") or ("—")) if owner else x.get("owner_username")) for x in rows],
105
+ title=f"{title} ({len(rows)})"))
106
+
107
+
108
+ def agent_options(f):
109
+ f = click.option("--mcp-file", "mcp_files", multiple=True, type=click.Path(), help="JSON file (or - for stdin) with MCP server objects/a list (name, url, transport, auth_type, token…).")(f)
110
+ f = click.option("--mcp", "mcp_pairs", multiple=True, metavar="NAME=URL", help="Attach an MCP server without auth (repeatable).")(f)
111
+ f = click.option("--payer", type=click.Choice(["caller", "owner"]), help="Who pays for each call (default: caller).")(f)
112
+ f = click.option("--registry", type=click.Choice(["published", "unlisted"]), help="Public agents: list in the discovery registry or keep unlisted.")(f)
113
+ f = click.option("--visibility", type=click.Choice(["private", "public"]), help="Who may call it (default: private).")(f)
114
+ f = click.option("--system-file", help="Read the system prompt from a file (or - for stdin).")(f)
115
+ f = click.option("--system", help="System prompt.")(f)
116
+ f = click.option("--description", help="Short description (max 500 characters).")(f)
117
+ return f
118
+
119
+
120
+ def body_from(name, model, description, system, system_file, visibility, registry, payer, mcp_pairs, mcp_files) -> Dict[str, Any]:
121
+ b: Dict[str, Any] = {}
122
+ for k, v in (("name", name), ("model", model), ("description", description), ("system_prompt", read_text(system, system_file)),
123
+ ("visibility", visibility), ("registry", registry), ("payer", payer)):
124
+ if v is not None:
125
+ b[k] = v
126
+ mcp = parse_mcp(mcp_pairs, mcp_files)
127
+ if mcp is not None:
128
+ b["mcp_servers"] = mcp
129
+ return b
130
+
131
+
132
+ # --------------------------------------------------------------------------- root
133
+
134
+
135
+ @click.group("agent", cls=IpxGroup, section="Cloud", short_help="Managed agents: create, share and chat",
136
+ examples=["ipx agent profile set acme-bot", "ipx agent create support --name \"Support\" --model lightning --system \"Be kind.\"",
137
+ "ipx agent list", "ipx agent chat acme-bot/support", "ipx agent invite create acme-bot/support dev@acme.com --role editor"])
138
+ def group() -> None:
139
+ """Build and share AI agents.
140
+
141
+ An agent is a backing model plus a system prompt and optional MCP servers, called anywhere as
142
+ `agent/<username>/<agent-id>`. Calls are billed at standard per-token rates to the caller or, if you choose
143
+ `--payer owner`, to you. Set an organization context to bill chats with an agent to the organization.
144
+ """
145
+
146
+
147
+ @group.command("list", short_help="Agents you can call")
148
+ @click.option("--mine", is_flag=True, help="Only agents you own.")
149
+ @pass_app
150
+ def list_(app: App, mine: bool) -> None:
151
+ """Your agents, agents shared with you, and public agents you have used."""
152
+ app.require_login()
153
+ d = api(app, "GET", "/v1/agents/models") or {}
154
+ rows = rows_of(d)
155
+ if mine:
156
+ rows = [r for r in rows if r.get("is_owner")]
157
+
158
+ def render(_: Any) -> None:
159
+ if not rows:
160
+ ui.info("No agents yet. Create one with `ipx agent create`, or browse `ipx agent registry`.")
161
+ return
162
+ agent_table(rows, "Agents")
163
+
164
+ app.out({**d, "data": rows} if isinstance(d, dict) else rows, render)
165
+
166
+
167
+ @group.command("registry", short_help="Browse public agents")
168
+ @click.option("-q", "--query", "q", help="Search by name, id or description.")
169
+ @click.option("--limit", type=click.IntRange(1, 500), default=50, show_default=True)
170
+ @pass_app
171
+ def registry(app: App, q: Optional[str], limit: int) -> None:
172
+ """Public agents that opted into the discovery registry."""
173
+ app.require_login()
174
+ d = api(app, "GET", "/v1/agents/registry", params={"q": q, "limit": limit}) or {}
175
+ rows = rows_of(d)
176
+ app.out(d, lambda _: agent_table(rows, "Agent registry", owner=False) if rows else ui.info("No public agents match."))
177
+
178
+
179
+ @group.command("get", short_help="Show an agent")
180
+ @click.argument("agent")
181
+ @pass_app
182
+ def get(app: App, agent: str) -> None:
183
+ """Details of AGENT (`username/agent-id`). Owners and editors also see the system prompt and members."""
184
+ app.require_login()
185
+ d = api(app, "GET", path(app, agent)) or {}
186
+ app.out(d, lambda x: show_agent(x.get("agent") or x))
187
+
188
+
189
+ @group.command("create", short_help="Create an agent", examples=[
190
+ "ipx agent create support --name Support --model lightning --system-file prompt.md --visibility public",
191
+ "ipx agent create docs-bot --name \"Docs bot\" --model openai/gpt-4o --mcp docs=https://mcp.example.com/mcp"])
192
+ @click.argument("agent_id")
193
+ @click.option("--name", required=True, help="Display name.")
194
+ @click.option("-m", "--model", required=True, help="Backing catalog model (see `ipx p2g models`).")
195
+ @agent_options
196
+ @pass_app
197
+ def create(app: App, agent_id, name, model, description, system, system_file, visibility, registry, payer, mcp_pairs, mcp_files) -> None:
198
+ """Create AGENT_ID (3+ lowercase letters, numbers, hyphens) under your agent username."""
199
+ app.require_login()
200
+ body = body_from(name, model, description, system, system_file, visibility, registry, payer, mcp_pairs, mcp_files)
201
+ body["agent_id"] = agent_id.lower()
202
+ d = api(app, "POST", "/v1/agents", body) or {}
203
+
204
+ def render(x: Dict[str, Any]) -> None:
205
+ ui.ok(f"Created agent [bold]{x.get('model_id')}[/bold]")
206
+ ui.dim(f"Chat with it: ipx agent chat {str(x.get('model_id') or '').removeprefix('agent/')}")
207
+
208
+ app.out(d, render)
209
+
210
+
211
+ @group.command("update", short_help="Change an agent")
212
+ @click.argument("agent")
213
+ @click.option("--name", help="New display name.")
214
+ @click.option("-m", "--model", help="New backing model.")
215
+ @agent_options
216
+ @pass_app
217
+ def update(app: App, agent, name, model, description, system, system_file, visibility, registry, payer, mcp_pairs, mcp_files) -> None:
218
+ """Update only the fields you pass. `--mcp`/`--mcp-file` replace the whole MCP server list."""
219
+ app.require_login()
220
+ body = body_from(name, model, description, system, system_file, visibility, registry, payer, mcp_pairs, mcp_files)
221
+ if not body:
222
+ raise IpxError("Nothing to change.", "Pass at least one option, e.g. --name or --system.")
223
+ d = api(app, "PATCH", path(app, agent), body) or {}
224
+ app.out(d, lambda x: ui.ok(f"Updated [bold]{x.get('model_id') or agent}[/bold]"))
225
+
226
+
227
+ @group.command("delete", short_help="Delete an agent")
228
+ @click.argument("agent")
229
+ @pass_app
230
+ def delete(app: App, agent: str) -> None:
231
+ """Permanently delete AGENT and revoke everyone's access to it."""
232
+ app.require_login()
233
+ p = path(app, agent)
234
+ if not ui.confirm(f"Delete agent [bold]{agent}[/bold]? This cannot be undone."):
235
+ raise click.Abort()
236
+ d = api(app, "DELETE", p) or {}
237
+ app.out(d, lambda _: ui.ok(f"Deleted [bold]{agent}[/bold]"))
238
+
239
+
240
+ @group.command("chat", short_help="Chat with an agent", examples=["ipx agent chat acme-bot/support"])
241
+ @click.argument("agent")
242
+ @pass_app
243
+ def chat_(app: App, agent: str) -> None:
244
+ """Interactive chat with AGENT (billing: you, or the organization context)."""
245
+ app.require_login()
246
+ user, aid = split_ref(app, agent)
247
+ model = f"agent/{user}/{aid}"
248
+ chatmod.run_repl(
249
+ title=f"Agent · {user}/{aid}", subtitle=f"billed to {'org ' + app.org if app.org else 'credits'}", backend=cloud.chat_backend(app, P2G),
250
+ model=model, system=None, list_models=lambda: [r["id"] for r in rows_of(api(app, "GET", "/v1/agents/models")) if r.get("id")],
251
+ footer_note=f"Billed to organization {app.org}." if app.org else None)
252
+
253
+
254
+ @group.command("ask", short_help="One-off question to an agent")
255
+ @click.argument("agent")
256
+ @click.argument("prompt", required=False)
257
+ @click.option("--no-stream", is_flag=True, help="Wait for the whole answer before printing.")
258
+ @pass_app
259
+ def ask(app: App, agent: str, prompt: Optional[str], no_stream: bool) -> None:
260
+ """Send PROMPT (or standard input) to AGENT and print the answer."""
261
+ app.require_login()
262
+ text = prompt if prompt and prompt != "-" else (sys.stdin.read().strip() if not sys.stdin.isatty() else None)
263
+ if not text:
264
+ raise IpxError("No prompt given.", "Pass it as an argument or pipe it in.")
265
+ user, aid = split_ref(app, agent)
266
+ chatmod.one_shot(backend=cloud.chat_backend(app, P2G), prompt=text, model=f"agent/{user}/{aid}", system=None,
267
+ as_json=app.json, stream_out=not no_stream)
268
+
269
+
270
+ # --------------------------------------------------------------------------- profile / settings
271
+
272
+
273
+ @group.group("profile", cls=IpxGroup, short_help="Your public agent username")
274
+ def profile() -> None:
275
+ """The public username that prefixes every agent you own."""
276
+
277
+
278
+ @profile.command("show")
279
+ @pass_app
280
+ def profile_show(app: App) -> None:
281
+ """Show your agent username."""
282
+ app.require_login()
283
+ d = api(app, "GET", "/v1/agents/profile") or {}
284
+ app.out(d, lambda x: ui.console.print(ui.kv_panel("Agent profile", [("Username", x.get("username") or "not set")])))
285
+
286
+
287
+ @profile.command("set")
288
+ @click.argument("username")
289
+ @pass_app
290
+ def profile_set(app: App, username: str) -> None:
291
+ """Set USERNAME (3-30 lowercase letters, numbers, hyphens, underscores)."""
292
+ app.require_login()
293
+ d = api(app, "PUT", "/v1/agents/profile", {"username": username}) or {}
294
+ app.out(d, lambda x: ui.ok(f"Agent username is now [bold]{x.get('username')}[/bold]"))
295
+
296
+
297
+ @group.group("settings", cls=IpxGroup, short_help="Agent preferences")
298
+ def settings() -> None:
299
+ """Preferences for how agents appear to you."""
300
+
301
+
302
+ @settings.command("show")
303
+ @pass_app
304
+ def settings_show(app: App) -> None:
305
+ """Show agent settings and the role capability matrix."""
306
+ app.require_login()
307
+ d = api(app, "GET", "/v1/agents/settings") or {}
308
+
309
+ def render(x: Dict[str, Any]) -> None:
310
+ s = x.get("settings") or {}
311
+ ui.console.print(ui.kv_panel("Agent settings", [("Include agents in /v1/models", "yes" if s.get("include_in_models") else "no")]))
312
+ ui.console.print(ui.make_table(["Role", "Can"], [(r.get("role"), ", ".join(r.get("capabilities") or [])) for r in x.get("roles") or []], title="Roles"))
313
+
314
+ app.out(d, render)
315
+
316
+
317
+ @settings.command("set")
318
+ @click.option("--include-in-models/--no-include-in-models", default=None, help="List your agents in the OpenAI-style /v1/models response.")
319
+ @pass_app
320
+ def settings_set(app: App, include_in_models: Optional[bool]) -> None:
321
+ """Change agent settings."""
322
+ app.require_login()
323
+ if include_in_models is None:
324
+ raise IpxError("Nothing to change.", "Pass --include-in-models or --no-include-in-models.")
325
+ d = api(app, "PUT", "/v1/agents/settings", {"include_in_models": include_in_models}) or {}
326
+ app.out(d, lambda _: ui.ok("Settings saved."))
327
+
328
+
329
+ @group.command("roles", short_help="What each sharing role can do")
330
+ @pass_app
331
+ def roles(app: App) -> None:
332
+ """Roles you can grant when inviting people to an agent."""
333
+ app.require_login()
334
+ d = api(app, "GET", "/v1/agents/settings") or {}
335
+ app.out(d.get("roles") or [], lambda rs: ui.console.print(ui.make_table(["Role", "Can"], [(r.get("role"), ", ".join(r.get("capabilities") or [])) for r in rs], title="Agent roles")))
336
+
337
+
338
+ # --------------------------------------------------------------------------- invites
339
+
340
+
341
+ @group.group("invite", cls=IpxGroup, short_help="Share an agent by invite")
342
+ def invite() -> None:
343
+ """Invite people to an agent, and answer invites you received. An invite grants nothing until accepted."""
344
+
345
+
346
+ @invite.command("create")
347
+ @click.argument("agent")
348
+ @click.argument("who", metavar="EMAIL_OR_USER_ID")
349
+ @click.option("--role", type=click.Choice(ROLES), default="user", show_default=True)
350
+ @pass_app
351
+ def invite_create(app: App, agent: str, who: str, role: str) -> None:
352
+ """Invite WHO to AGENT. Send them the printed link."""
353
+ app.require_login()
354
+ key = "user_id" if "@" not in who and len(who) >= 32 else "email"
355
+ d = api(app, "POST", path(app, agent).replace("/v1/agents/", "/v1/agents/invite/", 1), {key: who, "role": role}) or {}
356
+
357
+ def render(x: Dict[str, Any]) -> None:
358
+ ui.ok(f"Invited [bold]{who}[/bold] as {x.get('role_label') or role}")
359
+ ui.console.print(f" {x.get('invite_url')}")
360
+ if x.get("note"):
361
+ ui.dim(x["note"])
362
+
363
+ app.out(d, render)
364
+
365
+
366
+ @invite.command("list", short_help="Invites you received")
367
+ @pass_app
368
+ def invite_list(app: App) -> None:
369
+ """Invites sent to you, pending first."""
370
+ app.require_login()
371
+ d = api(app, "GET", "/v1/agents/invites") or {}
372
+ rows = rows_of(d, "invites")
373
+ app.out(d, lambda _: ui.console.print(ui.make_table(
374
+ ["Token", "Agent", "Role", "Status"],
375
+ [(i.get("invite_token"), f"{i.get('owner_username')}/{i.get('agent_id')}", i.get("role"), i.get("status")) for i in rows],
376
+ title=f"Agent invites ({len(rows)})")) if rows else ui.info("No invites."))
377
+
378
+
379
+ @invite.command("preview")
380
+ @click.argument("token")
381
+ @pass_app
382
+ def invite_preview(app: App, token: str) -> None:
383
+ """See what an invite TOKEN (or link) grants, and who pays, before accepting."""
384
+ app.require_login()
385
+ d = api(app, "GET", f"/v1/agents/invite/{token.rsplit('=', 1)[-1].rsplit('/', 1)[-1]}") or {}
386
+ app.out(d, lambda x: ui.console.print(ui.kv_panel("Agent invite", [(k.replace("_", " "), v) for k, v in x.items() if not isinstance(v, (dict, list))])))
387
+
388
+
389
+ for _name, _verb, _past in (("accept", "Accept", "Invite accepted."), ("deny", "Deny", "Invite denied.")):
390
+ def _make(name: str = _name, verb: str = _verb, past: str = _past) -> None:
391
+ @invite.command(name, short_help=f"{verb} an invite")
392
+ @click.argument("token")
393
+ @pass_app
394
+ def _cmd(app: App, token: str) -> None:
395
+ app.require_login()
396
+ d = api(app, "POST", f"/v1/agents/invite/{token.rsplit('=', 1)[-1].rsplit('/', 1)[-1]}/{name}", {}) or {}
397
+ app.out(d, lambda _: ui.ok(past))
398
+
399
+ _cmd.__doc__ = f"{verb} the invite TOKEN (the token or the full invite link)."
400
+
401
+ _make()
402
+
403
+
404
+ @invite.command("revoke")
405
+ @click.argument("token")
406
+ @pass_app
407
+ def invite_revoke(app: App, token: str) -> None:
408
+ """Withdraw a pending invite you sent."""
409
+ app.require_login()
410
+ d = api(app, "DELETE", f"/v1/agents/invite/{token.rsplit('=', 1)[-1].rsplit('/', 1)[-1]}") or {}
411
+ app.out(d, lambda _: ui.ok("Invite revoked."))
412
+
413
+
414
+ # --------------------------------------------------------------------------- members
415
+
416
+
417
+ @group.group("members", cls=IpxGroup, short_help="People with access to an agent")
418
+ def members() -> None:
419
+ """List and manage who can use an agent."""
420
+
421
+
422
+ @members.command("list")
423
+ @click.argument("agent")
424
+ @pass_app
425
+ def members_list(app: App, agent: str) -> None:
426
+ """Members of AGENT and their roles."""
427
+ app.require_login()
428
+ d = api(app, "GET", path(app, agent, "/members")) or {}
429
+ rows = rows_of(d, "members")
430
+ app.out(d, lambda _: ui.console.print(ui.make_table(["User", "Email", "Role", "Since"], [(m.get("user_id"), m.get("email"), m.get("role"), str(m.get("granted_at") or "")[:10]) for m in rows],
431
+ title=f"Members ({len(rows)})")) if rows else ui.info("No members yet."))
432
+
433
+
434
+ @members.command("role")
435
+ @click.argument("agent")
436
+ @click.argument("user_id")
437
+ @click.argument("role", type=click.Choice(ROLES))
438
+ @pass_app
439
+ def members_role(app: App, agent: str, user_id: str, role: str) -> None:
440
+ """Change a member's role."""
441
+ app.require_login()
442
+ d = api(app, "PATCH", path(app, agent, f"/members/{user_id}"), {"role": role}) or {}
443
+ app.out(d, lambda _: ui.ok(f"{user_id} is now {role}"))
444
+
445
+
446
+ @members.command("remove")
447
+ @click.argument("agent")
448
+ @click.argument("user_id")
449
+ @pass_app
450
+ def members_remove(app: App, agent: str, user_id: str) -> None:
451
+ """Remove a member from AGENT."""
452
+ app.require_login()
453
+ if not ui.confirm(f"Remove {user_id} from {agent}?"):
454
+ raise click.Abort()
455
+ d = api(app, "DELETE", path(app, agent, f"/members/{user_id}")) or {}
456
+ app.out(d, lambda _: ui.ok("Member removed."))
457
+
458
+
459
+ COMMANDS = [group]