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.
- inferenceport_cli-1.0.0.dist-info/METADATA +132 -0
- inferenceport_cli-1.0.0.dist-info/RECORD +39 -0
- inferenceport_cli-1.0.0.dist-info/WHEEL +5 -0
- inferenceport_cli-1.0.0.dist-info/entry_points.txt +2 -0
- inferenceport_cli-1.0.0.dist-info/licenses/LICENSE +201 -0
- inferenceport_cli-1.0.0.dist-info/top_level.txt +1 -0
- ipx/__init__.py +4 -0
- ipx/__main__.py +4 -0
- ipx/app.py +78 -0
- ipx/auth.py +260 -0
- ipx/chat.py +314 -0
- ipx/cli.py +142 -0
- ipx/clickx.py +160 -0
- ipx/cloud.py +340 -0
- ipx/commands/__init__.py +0 -0
- ipx/commands/agent.py +459 -0
- ipx/commands/auth.py +269 -0
- ipx/commands/cloudgen.py +559 -0
- ipx/commands/config_cmd.py +85 -0
- ipx/commands/featherless.py +252 -0
- ipx/commands/gen.py +3 -0
- ipx/commands/imports.py +198 -0
- ipx/commands/local.py +88 -0
- ipx/commands/mcp.py +182 -0
- ipx/commands/misc.py +168 -0
- ipx/commands/model.py +234 -0
- ipx/commands/org.py +915 -0
- ipx/commands/p2g.py +3 -0
- ipx/commands/server.py +328 -0
- ipx/commands/shield.py +1079 -0
- ipx/commands/usage.py +123 -0
- ipx/config.py +215 -0
- ipx/errors.py +41 -0
- ipx/http.py +373 -0
- ipx/local.py +90 -0
- ipx/mcp.py +134 -0
- ipx/policy.py +139 -0
- ipx/proxy.py +279 -0
- ipx/ui.py +360 -0
ipx/commands/mcp.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""ipx mcp - InferencePort's hosted MCP servers. Today: the Council of LLMs (`ipx mcp council`)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shlex
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
from rich.markup import escape
|
|
12
|
+
|
|
13
|
+
from ipx import ui
|
|
14
|
+
from ipx.app import App, pass_app
|
|
15
|
+
from ipx.clickx import IpxGroup
|
|
16
|
+
from ipx.errors import IpxError
|
|
17
|
+
from ipx.mcp import McpClient, council_url
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def council_client(app: App) -> McpClient:
|
|
21
|
+
app.require_login()
|
|
22
|
+
with ui.status("Connecting to the council…") if not app.json else _null():
|
|
23
|
+
return McpClient(app, council_url(app.api.base_url)).connect()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _null:
|
|
27
|
+
def __enter__(self) -> None:
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
def __exit__(self, *a: Any) -> None:
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _load(tool_result: Any) -> Any:
|
|
35
|
+
return tool_result if not isinstance(tool_result, str) else {"text": tool_result}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@click.group("mcp", cls=IpxGroup, section="Cloud", short_help="Hosted MCP servers (Council of LLMs)",
|
|
39
|
+
examples=["ipx mcp council tiers", "ipx mcp council reason \"Is P = NP provable with current methods?\" --tier basic",
|
|
40
|
+
"ipx -O acme mcp council reason -f question.md --rounds 2", "ipx mcp council connect claude-code"])
|
|
41
|
+
def group() -> None:
|
|
42
|
+
"""Use InferencePort's hosted Model Context Protocol servers from the terminal."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@group.group("council", cls=IpxGroup, short_help="Council of LLMs: multi-model reasoning")
|
|
46
|
+
def council() -> None:
|
|
47
|
+
"""Send a problem to a council of models that answer, cross-review each other, and have judges synthesize a result.
|
|
48
|
+
|
|
49
|
+
Each model call is billed at normal per-token rates plus a flat council surcharge, to your wallet or - with an
|
|
50
|
+
organization context (`ipx auth context <org>` or --org) - the organization's. Works with a signed-in session
|
|
51
|
+
or an API key.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@council.command("tiers", short_help="Council tiers and their cost")
|
|
56
|
+
@pass_app
|
|
57
|
+
def tiers(app: App) -> None:
|
|
58
|
+
"""List the council tiers with model counts, judges and the flat surcharge in credits."""
|
|
59
|
+
c = council_client(app)
|
|
60
|
+
d = c.call_json("council_list_tiers")
|
|
61
|
+
|
|
62
|
+
def render(x: Dict[str, Any]) -> None:
|
|
63
|
+
ui.console.print(ui.make_table(
|
|
64
|
+
["Tier", "Name", "Models", "Judges", "Surcharge", "Description"],
|
|
65
|
+
[(k, v.get("name"), v.get("model_count"), v.get("judge_count"), f"{ui.fmt_credits(v.get('surcharge_credits'))} cr", v.get("description")) for k, v in x.items()],
|
|
66
|
+
title="Council tiers", right=["Models", "Judges", "Surcharge"]))
|
|
67
|
+
|
|
68
|
+
app.out(d, render)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@council.command("models", short_help="Models in a tier")
|
|
72
|
+
@click.option("-t", "--tier", default="basic", show_default=True, help="Tier name (see `ipx mcp council tiers`).")
|
|
73
|
+
@pass_app
|
|
74
|
+
def models(app: App, tier: str) -> None:
|
|
75
|
+
"""The council members, judges and fallbacks of a tier."""
|
|
76
|
+
d = council_client(app).call_json("council_list_models", {"tier": tier})
|
|
77
|
+
|
|
78
|
+
def render(x: Dict[str, Any]) -> None:
|
|
79
|
+
ui.console.print(ui.make_table(["Role", "Model"], [("member", m) for m in x.get("models") or []] + [("judge", m) for m in x.get("judge_models") or []]
|
|
80
|
+
+ [("fallback", m) for m in x.get("fallback_pool") or []], title=f"{x.get('name') or tier} council"))
|
|
81
|
+
|
|
82
|
+
app.out(d, render)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@council.command("reason", short_help="Ask the council", examples=[
|
|
86
|
+
"ipx mcp council reason \"Design a rate limiter for 10k rps\"",
|
|
87
|
+
"ipx mcp council reason -f spec.md --tier pro --rounds 2",
|
|
88
|
+
"cat bug.txt | ipx mcp council reason --stdin \"What is the root cause?\"",
|
|
89
|
+
"ipx mcp council reason \"…\" --model openai/gpt-4o --model anthropic/claude-sonnet-4 --judge openai/gpt-4o"])
|
|
90
|
+
@click.argument("problem", required=False)
|
|
91
|
+
@click.option("-f", "--file", "files", multiple=True, type=click.Path(exists=True, dir_okay=False), help="Include a text file's contents (repeatable).")
|
|
92
|
+
@click.option("--stdin", "use_stdin", is_flag=True, help="Append standard input to the problem.")
|
|
93
|
+
@click.option("-t", "--tier", default="basic", show_default=True, help="Council tier; higher tiers use more and better models.")
|
|
94
|
+
@click.option("-r", "--rounds", type=click.IntRange(1, 3), default=1, show_default=True, help="Review rounds (models critique each other).")
|
|
95
|
+
@click.option("--model", "custom_models", multiple=True, help="Override the council members (repeatable).")
|
|
96
|
+
@click.option("--judge", "custom_judges", multiple=True, help="Override the judges (repeatable).")
|
|
97
|
+
@click.option("--log", "show_log", is_flag=True, help="Also print the per-round iteration log.")
|
|
98
|
+
@pass_app
|
|
99
|
+
def reason(app: App, problem: Optional[str], files: Tuple[str, ...], use_stdin: bool, tier: str, rounds: int,
|
|
100
|
+
custom_models: Tuple[str, ...], custom_judges: Tuple[str, ...], show_log: bool) -> None:
|
|
101
|
+
"""Run PROBLEM through the council and print the synthesized answer (stdout) with a usage summary (stderr)."""
|
|
102
|
+
app.require_login()
|
|
103
|
+
parts: List[str] = [problem] if problem and problem != "-" else []
|
|
104
|
+
for f in files:
|
|
105
|
+
with open(f, "r", encoding="utf-8", errors="replace") as fh:
|
|
106
|
+
parts.append(f"--- {f} ---\n{fh.read()}")
|
|
107
|
+
if use_stdin or problem == "-":
|
|
108
|
+
parts.append(sys.stdin.read().strip())
|
|
109
|
+
text = "\n\n".join(p for p in parts if p).strip()
|
|
110
|
+
if not text:
|
|
111
|
+
raise IpxError("No problem given.", "Pass it as an argument, with -f FILE, or pipe it in with --stdin.")
|
|
112
|
+
args: Dict[str, Any] = {"problem": text, "tier": tier, "iteration_rounds": rounds}
|
|
113
|
+
if custom_models:
|
|
114
|
+
args["custom_models"] = list(custom_models)
|
|
115
|
+
if custom_judges:
|
|
116
|
+
args["custom_judges"] = list(custom_judges)
|
|
117
|
+
c = council_client(app)
|
|
118
|
+
if not app.json:
|
|
119
|
+
ui.err.print(f"[muted]Convening the {tier} council · billed to {'org ' + app.org if app.org else 'your wallet'} · this can take a few minutes…[/muted]")
|
|
120
|
+
d = c.call_json("council_reason", args)
|
|
121
|
+
if not isinstance(d, dict):
|
|
122
|
+
d = {"answer": d}
|
|
123
|
+
if app.json:
|
|
124
|
+
if not show_log:
|
|
125
|
+
d.pop("iteration_log", None)
|
|
126
|
+
ui.emit_json(d)
|
|
127
|
+
return
|
|
128
|
+
sys.stdout.write(str(d.get("answer", "")).rstrip() + "\n")
|
|
129
|
+
info, bill, use = d.get("council") or {}, d.get("billing") or {}, d.get("usage") or {}
|
|
130
|
+
if not ui.STATE["quiet"]:
|
|
131
|
+
failed = info.get("failed_models") or []
|
|
132
|
+
line = [f"{len(info.get('models_used') or [])} models", f"{info.get('iterations_used', '?')} round(s)"]
|
|
133
|
+
if bill.get("surcharge_credits"):
|
|
134
|
+
line.append(f"surcharge {ui.fmt_credits(bill['surcharge_credits'])} cr" + ("" if bill.get("charged") else " (not charged)"))
|
|
135
|
+
if isinstance(use, dict) and use.get("total_tokens"):
|
|
136
|
+
line.append(f"{ui.fmt_num(use['total_tokens'])} tokens")
|
|
137
|
+
ui.err.print("[muted]" + " · ".join(line) + ("" if not failed else f" · {len(failed)} member(s) failed") + "[/muted]")
|
|
138
|
+
if show_log and d.get("iteration_log"):
|
|
139
|
+
ui.err.print_json(json.dumps(d["iteration_log"], default=str))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@council.command("tools", short_help="Tools the council server exposes")
|
|
143
|
+
@pass_app
|
|
144
|
+
def tools(app: App) -> None:
|
|
145
|
+
"""List the MCP tools offered by the council server."""
|
|
146
|
+
ts = council_client(app).list_tools()
|
|
147
|
+
app.out(ts, lambda rows: ui.console.print(ui.make_table(["Tool", "Description"], [(t.get("name"), (t.get("description") or "").strip().split("\n")[0]) for t in rows], title="Council MCP tools")))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@council.command("test", short_help="Check the connection")
|
|
151
|
+
@pass_app
|
|
152
|
+
def test(app: App) -> None:
|
|
153
|
+
"""Run the MCP handshake with your sign-in, like an MCP client would."""
|
|
154
|
+
c = council_client(app)
|
|
155
|
+
info = {"url": c.url, "server": c.server_info.get("name"), "version": c.server_info.get("version"), "org": app.org}
|
|
156
|
+
app.out(info, lambda x: ui.ok(f"Connected to [bold]{x['server']}[/bold] at {x['url']}" + (f" as org {x['org']}" if x["org"] else "")))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
CLIENTS = ["claude-code", "claude-desktop", "cursor", "vscode", "json"]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@council.command("connect", short_help="Config snippet for an MCP client")
|
|
163
|
+
@click.argument("client", type=click.Choice(CLIENTS), default="claude-code")
|
|
164
|
+
@pass_app
|
|
165
|
+
def connect(app: App, client: str) -> None:
|
|
166
|
+
"""Print the setup for CLIENT to use the council server. Uses OAuth, or paste an API key (ipa_live_…) as the token."""
|
|
167
|
+
url = council_url(app.api.base_url)
|
|
168
|
+
headers = {"X-InferencePort-Org": app.org} if app.org else {}
|
|
169
|
+
if client == "claude-code":
|
|
170
|
+
hdr = "".join(f" --header {shlex.quote(f'{k}: {v}')}" for k, v in headers.items())
|
|
171
|
+
text = f"claude mcp add --transport http council {shlex.quote(url)}{hdr}"
|
|
172
|
+
elif client == "claude-desktop":
|
|
173
|
+
text = json.dumps({"mcpServers": {"council": {"command": "npx", "args": ["-y", "mcp-remote", url] + [x for k, v in headers.items() for x in ("--header", f"{k}:{v}")]}}}, indent=2)
|
|
174
|
+
else:
|
|
175
|
+
server: Dict[str, Any] = {"type": "http", "url": url} if client == "vscode" else {"url": url}
|
|
176
|
+
if headers:
|
|
177
|
+
server["headers"] = headers
|
|
178
|
+
text = json.dumps({"servers" if client == "vscode" else "mcpServers": {"council": server}}, indent=2)
|
|
179
|
+
app.out({"client": client, "url": url, "config": text}, lambda _: (ui.err.print(f"[muted]{client} · billed to {'org ' + escape(app.org) if app.org else 'your personal wallet'}[/muted]", markup=True, highlight=False) if not ui.STATE["quiet"] else None, sys.stdout.write(text + "\n")))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
COMMANDS = [group]
|
ipx/commands/misc.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Top-level utility commands: status, doctor, api, rewards, completion."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import socket
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
|
|
13
|
+
from ipx import __version__, ui
|
|
14
|
+
from ipx.app import App, pass_app
|
|
15
|
+
from ipx.clickx import IpxCommand, IpxGroup
|
|
16
|
+
from ipx.config import DEFAULT_SERVER_PORT, desktop_data_dir
|
|
17
|
+
from ipx.errors import IpxError
|
|
18
|
+
|
|
19
|
+
_STATE_STYLE = {"operational": "ok", "up": "ok", "ok": "ok", "degraded": "warn", "partial": "warn", "down": "bad", "outage": "bad"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _dot(state: Any) -> Text:
|
|
23
|
+
s = str(state).lower()
|
|
24
|
+
style = _STATE_STYLE.get(s, "muted")
|
|
25
|
+
return Text(f"● {state}", style=style)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@click.command("status", cls=IpxCommand, section="Tools", short_help="InferencePort service health")
|
|
29
|
+
@pass_app
|
|
30
|
+
def status(app: App) -> None:
|
|
31
|
+
"""Show the health of the Lightning API and each generation service (no sign-in needed)."""
|
|
32
|
+
data = app.api.get("/status", auth=False, org=False) or {}
|
|
33
|
+
|
|
34
|
+
def render(d: Dict[str, Any]) -> None:
|
|
35
|
+
rows: List[Any] = [("Overall", _dot(d.get("state", "unknown")))]
|
|
36
|
+
for name, val in (d.get("services") or {}).items():
|
|
37
|
+
state = val.get("state") or val.get("status") if isinstance(val, dict) else val
|
|
38
|
+
rows.append((name, _dot(state)))
|
|
39
|
+
ui.console.print(ui.kv_panel("InferencePort status", rows, subtitle=app.cfg.base_url))
|
|
40
|
+
for n in d.get("notifications") or []:
|
|
41
|
+
ui.warn(n if isinstance(n, str) else n.get("message") or json.dumps(n))
|
|
42
|
+
|
|
43
|
+
app.out(data, render)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@click.command("doctor", cls=IpxCommand, section="Tools", short_help="Diagnose your setup")
|
|
47
|
+
@pass_app
|
|
48
|
+
def doctor(app: App) -> None:
|
|
49
|
+
"""Check connectivity, sign-in, organization context, local Ollama and the host-server port."""
|
|
50
|
+
from ipx.local import ollama_version
|
|
51
|
+
|
|
52
|
+
checks: List[Dict[str, Any]] = []
|
|
53
|
+
|
|
54
|
+
def add(name: str, ok_: Optional[bool], detail: str) -> None:
|
|
55
|
+
checks.append({"check": name, "ok": ok_, "detail": detail})
|
|
56
|
+
|
|
57
|
+
add("ipx version", True, __version__)
|
|
58
|
+
try:
|
|
59
|
+
st = app.api.get("/status", auth=False, org=False) or {}
|
|
60
|
+
add("API reachable", True, f"{app.cfg.base_url} · {st.get('state', 'ok')}")
|
|
61
|
+
except IpxError as e:
|
|
62
|
+
add("API reachable", False, e.message)
|
|
63
|
+
if app.creds.signed_in:
|
|
64
|
+
try:
|
|
65
|
+
sub = app.api.get("/subscription", org=False) or {}
|
|
66
|
+
add("Signed in", True, f"{sub.get('email') or app.creds.email} · {sub.get('plan_name', 'plan unknown')}")
|
|
67
|
+
except IpxError as e:
|
|
68
|
+
add("Signed in", False, e.message)
|
|
69
|
+
else:
|
|
70
|
+
add("Signed in", None, "not signed in — run `ipx auth login`")
|
|
71
|
+
add("Organization context", None if not app.cfg.org else True, app.cfg.org or "personal account")
|
|
72
|
+
v = ollama_version(app.cfg.ollama_url)
|
|
73
|
+
add("Local Ollama", bool(v), f"{app.cfg.ollama_url} · v{v}" if v else "not running — install from https://ollama.com and run `ollama serve`")
|
|
74
|
+
with socket.socket() as s:
|
|
75
|
+
s.settimeout(0.3)
|
|
76
|
+
busy = s.connect_ex(("127.0.0.1", DEFAULT_SERVER_PORT)) == 0
|
|
77
|
+
add("Host server port", None, f"{DEFAULT_SERVER_PORT} in use (server may be running)" if busy else f"{DEFAULT_SERVER_PORT} is free")
|
|
78
|
+
dd = desktop_data_dir()
|
|
79
|
+
add("Desktop app data", True if dd.exists() else None, str(dd) if dd.exists() else "desktop app not found (imports will create the folder)")
|
|
80
|
+
|
|
81
|
+
def render(rows: List[Dict[str, Any]]) -> None:
|
|
82
|
+
for r in rows:
|
|
83
|
+
mark = {True: "[ok]✔[/ok]", False: "[bad]✖[/bad]", None: "[warn]•[/warn]"}[r["ok"]]
|
|
84
|
+
ui.console.print(f" {mark} [bold]{r['check']:<22}[/bold] [muted]{r['detail']}[/muted]")
|
|
85
|
+
|
|
86
|
+
app.out(checks, render)
|
|
87
|
+
if any(r["ok"] is False for r in checks):
|
|
88
|
+
sys.exit(1)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@click.command("api", cls=IpxCommand, section="Tools", short_help="Make a raw authenticated API request",
|
|
92
|
+
examples=["ipx api GET /v1/me", "ipx api POST /v1/chat/completions -d '{\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}'"])
|
|
93
|
+
@click.argument("method")
|
|
94
|
+
@click.argument("path")
|
|
95
|
+
@click.option("-d", "--data", help="JSON request body (or @file.json).")
|
|
96
|
+
@click.option("-q", "--query", "queries", multiple=True, help="Query parameter key=value (repeatable).")
|
|
97
|
+
@click.option("--no-auth", is_flag=True, help="Do not send credentials.")
|
|
98
|
+
@pass_app
|
|
99
|
+
def api(app: App, method: str, path: str, data: Optional[str], queries, no_auth: bool) -> None:
|
|
100
|
+
"""Call any endpoint with your credentials and print the response. Escape hatch for scripting."""
|
|
101
|
+
body: Any = None
|
|
102
|
+
if data:
|
|
103
|
+
raw = open(data[1:], "r", encoding="utf-8").read() if data.startswith("@") else data
|
|
104
|
+
try:
|
|
105
|
+
body = json.loads(raw)
|
|
106
|
+
except ValueError as e:
|
|
107
|
+
raise IpxError(f"--data is not valid JSON: {e}")
|
|
108
|
+
params = dict(q.split("=", 1) for q in queries if "=" in q)
|
|
109
|
+
r = app.api.request(method.upper(), path, json=body, params=params, auth=not no_auth)
|
|
110
|
+
if r.content_type.startswith(("application/json",)) or r.body[:1] in (b"{", b"["):
|
|
111
|
+
try:
|
|
112
|
+
ui.console.print_json(r.text) if ui.console.is_terminal and not app.json else click.echo(r.text)
|
|
113
|
+
return
|
|
114
|
+
except Exception:
|
|
115
|
+
pass
|
|
116
|
+
sys.stdout.buffer.write(r.body)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@click.group("rewards", cls=IpxGroup, section="Tools", short_help="Rewards and discounts")
|
|
120
|
+
def rewards() -> None:
|
|
121
|
+
"""Earn and claim InferencePort rewards."""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@rewards.command("list")
|
|
125
|
+
@pass_app
|
|
126
|
+
def rewards_list(app: App) -> None:
|
|
127
|
+
"""List available rewards."""
|
|
128
|
+
data = app.api.get("/v1/rewards", org=False) or {}
|
|
129
|
+
items = data.get("rewards") or data.get("items") or (data if isinstance(data, list) else [])
|
|
130
|
+
|
|
131
|
+
def render(_: Any) -> None:
|
|
132
|
+
rows = [(r.get("id"), r.get("title") or r.get("name"), r.get("description") or "") for r in items if isinstance(r, dict)]
|
|
133
|
+
ui.console.print(ui.make_table(["ID", "Reward", "Description"], rows, title="Rewards"))
|
|
134
|
+
|
|
135
|
+
app.out(data, render)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@rewards.command("summary")
|
|
139
|
+
@pass_app
|
|
140
|
+
def rewards_summary(app: App) -> None:
|
|
141
|
+
"""Your reward progress at a glance."""
|
|
142
|
+
data = app.api.get("/v1/rewards/summary", org=False) or {}
|
|
143
|
+
app.out(data, lambda d: ui.console.print(ui.kv_panel("Rewards", [(k, v) for k, v in d.items() if not isinstance(v, (dict, list))])))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@rewards.command("claim")
|
|
147
|
+
@click.argument("reward_id")
|
|
148
|
+
@pass_app
|
|
149
|
+
def rewards_claim(app: App, reward_id: str) -> None:
|
|
150
|
+
"""Claim an earned reward."""
|
|
151
|
+
data = app.api.post(f"/v1/rewards/claim/{reward_id}", {}, org=False)
|
|
152
|
+
app.out(data, lambda _: ui.ok(f"Claimed reward [bold]{reward_id}[/bold]"))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@click.command("completion", cls=IpxCommand, section="Tools", short_help="Print a shell completion script",
|
|
156
|
+
examples=["ipx completion bash >> ~/.bashrc", "ipx completion zsh > ~/.zfunc/_ipx", "ipx completion fish > ~/.config/fish/completions/ipx.fish"])
|
|
157
|
+
@click.argument("shell", type=click.Choice(["bash", "zsh", "fish"]))
|
|
158
|
+
def completion(shell: str) -> None:
|
|
159
|
+
"""Print a completion script for your shell."""
|
|
160
|
+
from click.shell_completion import get_completion_class
|
|
161
|
+
|
|
162
|
+
from ipx.cli import root
|
|
163
|
+
|
|
164
|
+
cls = get_completion_class(shell)
|
|
165
|
+
click.echo(cls(root, {}, "ipx", "_IPX_COMPLETE").source())
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
COMMANDS = [status, doctor, api, rewards, completion]
|
ipx/commands/model.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""ipx model - manage models on this machine (the Ollama runtime used by the desktop app)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy as _copy
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any, Dict, Optional
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
|
|
13
|
+
from ipx import policy, ui
|
|
14
|
+
from ipx.app import App, pass_app
|
|
15
|
+
from ipx.clickx import IpxGroup
|
|
16
|
+
from ipx.local import Ollama
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _clip(s: str, n: int) -> str:
|
|
20
|
+
s = " ".join(s.split())
|
|
21
|
+
return s if len(s) <= n else s[: n - 1].rsplit(" ", 1)[0] + "…"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def client(app: App) -> Ollama:
|
|
25
|
+
return Ollama(app.cfg.ollama_url)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@click.group("model", cls=IpxGroup, section="Local", short_help="Pull, list, inspect and remove local models",
|
|
29
|
+
examples=["ipx model list", "ipx model pull llama3.2", "ipx model search coder", "ipx model rm llama3.2:1b",
|
|
30
|
+
"ipx model import gguf ./my-model.gguf --name mine", "ipx model import hf bartowski/Llama-3.2-3B-Instruct-GGUF"])
|
|
31
|
+
def group() -> None:
|
|
32
|
+
"""Manage local models on your machine.
|
|
33
|
+
|
|
34
|
+
Uses the same Ollama runtime as the InferencePort desktop app, so models you pull here appear there (and vice
|
|
35
|
+
versa). Organization controls on pulling and importing apply when an org context is active.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
group_alias_name = "models"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@group.command("list", short_help="Installed models")
|
|
43
|
+
@pass_app
|
|
44
|
+
def list_(app: App) -> None:
|
|
45
|
+
"""List models installed locally."""
|
|
46
|
+
models = client(app).list()
|
|
47
|
+
|
|
48
|
+
def render(_: Any) -> None:
|
|
49
|
+
if not models:
|
|
50
|
+
ui.info("No local models yet. Try `ipx model pull llama3.2`.")
|
|
51
|
+
return
|
|
52
|
+
rows = []
|
|
53
|
+
for m in sorted(models, key=lambda x: x.get("name", "")):
|
|
54
|
+
d = m.get("details") or {}
|
|
55
|
+
rows.append((m.get("name"), ui.fmt_bytes(m.get("size", 0)), d.get("parameter_size"), d.get("quantization_level"),
|
|
56
|
+
d.get("family"), (m.get("modified_at") or "")[:10]))
|
|
57
|
+
ui.console.print(ui.make_table(["Model", "Size", "Params", "Quant", "Family", "Modified"], rows,
|
|
58
|
+
title=f"Local models ({len(rows)})", right=["Size"]))
|
|
59
|
+
|
|
60
|
+
app.out(models, render)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@group.command("pull", examples=["ipx model pull llama3.2", "ipx model pull qwen2.5-coder:7b"])
|
|
64
|
+
@click.argument("names", nargs=-1, required=True)
|
|
65
|
+
@pass_app
|
|
66
|
+
def pull(app: App, names) -> None:
|
|
67
|
+
"""Download one or more models with a live progress bar."""
|
|
68
|
+
oll = client(app)
|
|
69
|
+
for name in names:
|
|
70
|
+
policy.assert_pull(app, name)
|
|
71
|
+
_pull_one(app, oll, name)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _pull_one(app: App, oll: Ollama, name: str) -> None:
|
|
75
|
+
last: Dict[str, Any] = {}
|
|
76
|
+
if app.json:
|
|
77
|
+
for ev in oll.pull(name):
|
|
78
|
+
last = ev
|
|
79
|
+
ui.emit_json({"model": name, "status": last.get("status")})
|
|
80
|
+
return
|
|
81
|
+
tasks: Dict[str, int] = {}
|
|
82
|
+
with ui.download_progress() as prog:
|
|
83
|
+
status_task = prog.add_task(f"Pulling {name}", total=None)
|
|
84
|
+
for ev in oll.pull(name):
|
|
85
|
+
last = ev
|
|
86
|
+
st = str(ev.get("status", ""))
|
|
87
|
+
digest = ev.get("digest")
|
|
88
|
+
if digest and ev.get("total"):
|
|
89
|
+
tid = tasks.get(digest)
|
|
90
|
+
if tid is None:
|
|
91
|
+
tid = prog.add_task(f"layer {digest.split(':')[-1][:10]}", total=ev["total"])
|
|
92
|
+
tasks[digest] = tid
|
|
93
|
+
prog.update(tid, completed=ev.get("completed", 0), total=ev["total"])
|
|
94
|
+
else:
|
|
95
|
+
prog.update(status_task, description=f"{name} · {st}")
|
|
96
|
+
prog.update(status_task, total=1, completed=1, description=f"{name} · done")
|
|
97
|
+
if last.get("status") == "success" or last:
|
|
98
|
+
ui.ok(f"Pulled [bold]{name}[/bold]")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@group.command("rm", short_help="Remove models")
|
|
102
|
+
@click.argument("names", nargs=-1, required=True)
|
|
103
|
+
@pass_app
|
|
104
|
+
def rm(app: App, names) -> None:
|
|
105
|
+
"""Delete local models and free the disk space."""
|
|
106
|
+
oll = client(app)
|
|
107
|
+
if not ui.confirm(f"Delete {', '.join(names)}?"):
|
|
108
|
+
return
|
|
109
|
+
for n in names:
|
|
110
|
+
oll.delete(n)
|
|
111
|
+
ui.ok(f"Removed [bold]{n}[/bold]")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@group.command("show", short_help="Details about a model")
|
|
115
|
+
@click.argument("name")
|
|
116
|
+
@pass_app
|
|
117
|
+
def show_(app: App, name: str) -> None:
|
|
118
|
+
"""Show a model's parameters, template and capabilities."""
|
|
119
|
+
d = client(app).show(name)
|
|
120
|
+
|
|
121
|
+
def render(d: Dict[str, Any]) -> None:
|
|
122
|
+
det = d.get("details") or {}
|
|
123
|
+
info = d.get("model_info") or {}
|
|
124
|
+
rows = [("Family", det.get("family")), ("Parameters", det.get("parameter_size")), ("Quantization", det.get("quantization_level")),
|
|
125
|
+
("Format", det.get("format")), ("Capabilities", ", ".join(d.get("capabilities") or []) or None),
|
|
126
|
+
("Context length", next((v for k, v in info.items() if k.endswith("context_length")), None))]
|
|
127
|
+
ui.console.print(ui.kv_panel(name, rows))
|
|
128
|
+
if d.get("system"):
|
|
129
|
+
ui.console.print(Text("System prompt", style="key"))
|
|
130
|
+
ui.console.print(Text(d["system"].strip(), style="muted"))
|
|
131
|
+
if d.get("modelfile") and not app.json:
|
|
132
|
+
ui.dim("Use `--json` for the full Modelfile and template.")
|
|
133
|
+
|
|
134
|
+
app.out(d, render)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@group.command("ps", short_help="Models loaded in memory")
|
|
138
|
+
@pass_app
|
|
139
|
+
def ps(app: App) -> None:
|
|
140
|
+
"""Show which models are currently loaded."""
|
|
141
|
+
run = client(app).running()
|
|
142
|
+
rows = [(m.get("name"), ui.fmt_bytes(m.get("size", 0)), ui.fmt_bytes(m.get("size_vram", 0)), (m.get("expires_at") or "")[:19].replace("T", " ")) for m in run]
|
|
143
|
+
app.out(run, lambda _: ui.console.print(ui.make_table(["Model", "Size", "VRAM", "Unloads at"], rows, title="Running models", right=["Size", "VRAM"])) if rows else ui.info("No models are loaded right now."))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@group.command("copy", short_help="Copy a model to a new name")
|
|
147
|
+
@click.argument("source")
|
|
148
|
+
@click.argument("destination")
|
|
149
|
+
@pass_app
|
|
150
|
+
def copy(app: App, source: str, destination: str) -> None:
|
|
151
|
+
"""Duplicate a model under another name."""
|
|
152
|
+
client(app).copy(source, destination)
|
|
153
|
+
ui.ok(f"Copied [bold]{source}[/bold] → [bold]{destination}[/bold]")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@group.command("create", examples=["ipx model create my-assistant -f Modelfile"])
|
|
157
|
+
@click.argument("name")
|
|
158
|
+
@click.option("-f", "--file", "modelfile", required=True, type=click.Path(exists=True, dir_okay=False), help="Path to a Modelfile.")
|
|
159
|
+
@pass_app
|
|
160
|
+
def create(app: App, name: str, modelfile: str) -> None:
|
|
161
|
+
"""Create a model from a Modelfile."""
|
|
162
|
+
policy.assert_import(app)
|
|
163
|
+
content = open(modelfile, "r", encoding="utf-8").read()
|
|
164
|
+
_create(app, name, modelfile=content)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _create(app: App, name: str, **body: Any) -> None:
|
|
168
|
+
last = ""
|
|
169
|
+
with ui.status("Creating model…") as st:
|
|
170
|
+
for ev in client(app).create(name, **body):
|
|
171
|
+
last = str(ev.get("status", ""))
|
|
172
|
+
if st:
|
|
173
|
+
st.update(f"[accent]{last}[/accent]")
|
|
174
|
+
ui.ok(f"Created [bold]{name}[/bold] [muted]({last})[/muted]")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@group.command("search", examples=["ipx model search vision", "ipx model search llama --limit 5"])
|
|
178
|
+
@click.argument("query", required=False)
|
|
179
|
+
@click.option("--limit", type=int, default=15, show_default=True)
|
|
180
|
+
@pass_app
|
|
181
|
+
def search(app: App, query: Optional[str], limit: int) -> None:
|
|
182
|
+
"""Search the public model catalog (no sign-in needed)."""
|
|
183
|
+
with ui.status("Searching the catalog…"):
|
|
184
|
+
cat = app.api.get("/models", auth=False, org=False) or []
|
|
185
|
+
q = (query or "").lower()
|
|
186
|
+
hits = [m for m in cat if isinstance(m, dict) and (not q or q in str(m.get("name", "")).lower() or q in str(m.get("description", "")).lower()
|
|
187
|
+
or any(q in str(t).lower() for t in m.get("tags") or []))]
|
|
188
|
+
hits = hits[:limit]
|
|
189
|
+
|
|
190
|
+
def render(_: Any) -> None:
|
|
191
|
+
rows = [(m.get("name"), ", ".join(m.get("sizes") or []), m.get("pulls"), ", ".join(t for t in m.get("tags") or [] if not re.fullmatch(r"[\d.]+[bm]", str(t).lower())), _clip(m.get("description") or "", 64)) for m in hits]
|
|
192
|
+
ui.console.print(ui.make_table(["Model", "Sizes", "Pulls", "Tags", "About"], rows, title=f"Catalog results ({len(hits)})"))
|
|
193
|
+
ui.dim("Install with `ipx model pull <name>`")
|
|
194
|
+
|
|
195
|
+
app.out(hits, render)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@group.group("import", cls=IpxGroup, short_help="Import GGUF files or Hugging Face models")
|
|
199
|
+
def import_() -> None:
|
|
200
|
+
"""Bring your own models: a local GGUF file or a Hugging Face repository."""
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@import_.command("gguf", examples=["ipx model import gguf ./model.Q4_K_M.gguf --name my-model --system \"You are concise.\""])
|
|
204
|
+
@click.argument("path", type=click.Path(exists=True, dir_okay=False))
|
|
205
|
+
@click.option("--name", required=True, help="Name for the imported model.")
|
|
206
|
+
@click.option("--system", help="Optional system prompt baked into the model.")
|
|
207
|
+
@click.option("--template", help="Optional chat template.")
|
|
208
|
+
@pass_app
|
|
209
|
+
def import_gguf(app: App, path: str, name: str, system: Optional[str], template: Optional[str]) -> None:
|
|
210
|
+
"""Import a local .gguf file as a model."""
|
|
211
|
+
policy.assert_import(app)
|
|
212
|
+
lines = [f'FROM {os.path.abspath(path)}']
|
|
213
|
+
if template:
|
|
214
|
+
lines.append(f'TEMPLATE """{template}"""')
|
|
215
|
+
if system:
|
|
216
|
+
lines.append(f'SYSTEM """{system}"""')
|
|
217
|
+
_create(app, name, modelfile="\n".join(lines))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@import_.command("hf", examples=["ipx model import hf bartowski/Llama-3.2-3B-Instruct-GGUF:Q4_K_M"])
|
|
221
|
+
@click.argument("repo")
|
|
222
|
+
@pass_app
|
|
223
|
+
def import_hf(app: App, repo: str) -> None:
|
|
224
|
+
"""Pull a GGUF model straight from Hugging Face (user/repo[:quant])."""
|
|
225
|
+
policy.assert_import(app)
|
|
226
|
+
ref = repo if repo.startswith("hf.co/") else f"hf.co/{repo}"
|
|
227
|
+
_pull_one(app, client(app), ref)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
_alias = _copy.copy(group)
|
|
231
|
+
_alias.name = "models"
|
|
232
|
+
_alias.hidden = True
|
|
233
|
+
|
|
234
|
+
COMMANDS = [group, _alias]
|