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
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""ipx featherless - Featherless model deployments, personal or for an organization.
|
|
2
|
+
|
|
3
|
+
In an organization context (`ipx auth context <org>` or --org) every command acts on the organization's
|
|
4
|
+
deployments: the fee is billed to the org wallet and every member that may spend on the org can call the model.
|
|
5
|
+
Use --personal to work on your own deployments instead.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
|
|
15
|
+
from ipx import ui
|
|
16
|
+
from ipx.app import App, pass_app
|
|
17
|
+
from ipx.clickx import IpxGroup
|
|
18
|
+
from ipx.errors import IpxError
|
|
19
|
+
|
|
20
|
+
STATE_STYLE = {"warm": "ok", "loading": "warn", "cold": "bad"}
|
|
21
|
+
LIVE = ("active", "cancelled_pending")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def hf(model: str) -> str:
|
|
25
|
+
"""Model ids are written `Author/Model` or `hf/Author/Model`; the API wants the prefixed form."""
|
|
26
|
+
m = model.strip()
|
|
27
|
+
return m if m.startswith("hf/") else f"hf/{m}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def bare(model: str) -> str:
|
|
31
|
+
m = model.strip()
|
|
32
|
+
return m[3:] if m.startswith("hf/") else m
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def base(app: App) -> str:
|
|
36
|
+
"""Deployments endpoint for the active context: the organization's, or the personal one."""
|
|
37
|
+
org = app.org
|
|
38
|
+
return f"/v1/orgs/{org}/deployments" if org else "/v1/deploy"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def auth(app: App) -> None:
|
|
42
|
+
"""Organization routes need an interactive session; personal ones also accept a personal API key."""
|
|
43
|
+
app.require_jwt("Organization deployments") if app.org else app.require_login()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def owner_label(app: App) -> str:
|
|
47
|
+
return f"organization {app.org}" if app.org else "your account"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def money(v: Any) -> str:
|
|
51
|
+
try:
|
|
52
|
+
return f"${float(v):.2f}"
|
|
53
|
+
except (TypeError, ValueError):
|
|
54
|
+
return "—"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def date(v: Any) -> str:
|
|
58
|
+
return str(v)[:10] if v else "—"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def status_text(s: Any) -> Text:
|
|
62
|
+
s = str(s or "")
|
|
63
|
+
style = {"active": "ok", "cancelled_pending": "warn"}.get(s, "muted")
|
|
64
|
+
return Text("canceling" if s == "cancelled_pending" else s or "—", style=style)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def state_text(s: Any) -> Text:
|
|
68
|
+
s = str(s or "")
|
|
69
|
+
label = {"warm": "warm", "loading": "initializing", "cold": "cold"}.get(s, s or "—")
|
|
70
|
+
return Text(label, style=STATE_STYLE.get(s, "muted"))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def deployments_of(d: Any) -> List[Dict[str, Any]]:
|
|
74
|
+
"""Rows from a list response, whatever the envelope (bare list, or data/deployments/models/items)."""
|
|
75
|
+
rows = d
|
|
76
|
+
if isinstance(d, dict):
|
|
77
|
+
rows = next((d[k] for k in ("data", "deployments", "models", "items") if isinstance(d.get(k), list)), [])
|
|
78
|
+
return [x for x in (rows or []) if isinstance(x, dict)]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def meta_of(d: Any) -> Dict[str, Any]:
|
|
82
|
+
return d if isinstance(d, dict) else {}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def show_deployment(x: Dict[str, Any], title: Optional[str] = None) -> None:
|
|
86
|
+
rows = [
|
|
87
|
+
("Model", x.get("featherless_model_id")),
|
|
88
|
+
("Call as", x.get("display_model_id")),
|
|
89
|
+
("Status", status_text(x.get("status"))),
|
|
90
|
+
("Model state", state_text(x.get("model_state")) if x.get("model_state") else None),
|
|
91
|
+
("Context", f"{int(x['context_length']):,} tokens" if x.get("context_length") else None),
|
|
92
|
+
("Monthly fee", money(x.get("monthly_fee"))),
|
|
93
|
+
("Prompt / completion", f"{money(x.get('pricing_prompt_per_million'))} / {money(x.get('pricing_completion_per_million'))} per M tokens"),
|
|
94
|
+
("Cycle", f"{date(x.get('billing_cycle_start'))} → {date(x.get('billing_cycle_end'))}"),
|
|
95
|
+
("Owner", f"org {x['org_id']}" if x.get("org_id") else "personal"),
|
|
96
|
+
("ID", x.get("id")),
|
|
97
|
+
]
|
|
98
|
+
ui.console.print(ui.kv_panel(title or str(x.get("featherless_model_id") or "Deployment"), [r for r in rows if r[1] is not None]))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@click.group("featherless", cls=IpxGroup, section="Cloud", short_help="Featherless deployments (personal or organization)",
|
|
102
|
+
examples=["ipx featherless models --search qwen", "ipx featherless deploy Qwen/Qwen2.5-7B-Instruct",
|
|
103
|
+
"ipx featherless list", "ipx -O acme featherless deploy meta-llama/Llama-3.1-8B-Instruct",
|
|
104
|
+
"ipx featherless cancel 3f2a…"])
|
|
105
|
+
def group() -> None:
|
|
106
|
+
"""Deploy any model from Featherless AI and manage your deployments.
|
|
107
|
+
|
|
108
|
+
A deployment costs $0.50/month and token usage is billed at Featherless rates plus a small markup, always from
|
|
109
|
+
paid credits. With an organization context the deployment belongs to the organization and bills its wallet;
|
|
110
|
+
only owners and admins can deploy, cancel, restore and terminate. Call a deployed model with the id shown by
|
|
111
|
+
`ipx featherless list` (hf/Author/Model) through any chat command.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@group.command("models", short_help="Browse the Featherless catalog")
|
|
116
|
+
@click.option("-s", "--search", help="Only models whose id contains this text.")
|
|
117
|
+
@click.option("--page", type=click.IntRange(1), default=1, show_default=True)
|
|
118
|
+
@click.option("--limit", type=click.IntRange(1, 200), default=25, show_default=True)
|
|
119
|
+
@pass_app
|
|
120
|
+
def models(app: App, search: Optional[str], page: int, limit: int) -> None:
|
|
121
|
+
"""Search the catalog of deployable models with their prices (no sign-in needed)."""
|
|
122
|
+
d = app.api.get("/v1/deploy/models", params={"search": search, "page": page, "limit": limit}, auth=False, org=False) or {}
|
|
123
|
+
|
|
124
|
+
def render(d: Any) -> None:
|
|
125
|
+
rows = []
|
|
126
|
+
meta = meta_of(d)
|
|
127
|
+
for m in deployments_of(d):
|
|
128
|
+
ours = m.get("our_pricing_per_million") or {}
|
|
129
|
+
rows.append((m.get("featherless_model_id") or m.get("id"), f"{int(m['context_length']):,}" if m.get("context_length") else "—",
|
|
130
|
+
money(ours.get("input")), money(ours.get("output"))))
|
|
131
|
+
ui.console.print(ui.make_table(["Model", "Context", "In $/M", "Out $/M"], rows,
|
|
132
|
+
title=f"Featherless models · {meta.get('total', len(rows)):,} found · page {meta.get('page', page)}/{meta.get('total_pages', 1)}",
|
|
133
|
+
right=["Context", "In $/M", "Out $/M"]))
|
|
134
|
+
|
|
135
|
+
app.out(d, render)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@group.command("status", short_help="Is a model warm or cold?")
|
|
139
|
+
@click.argument("model")
|
|
140
|
+
@pass_app
|
|
141
|
+
def status(app: App, model: str) -> None:
|
|
142
|
+
"""Whether MODEL is loaded (warm), loading, or cold. Cold models take 5-15 minutes on the first request."""
|
|
143
|
+
d = app.api.get(f"/v1/deploy/model-status/{bare(model)}", auth=False, org=False) or {}
|
|
144
|
+
app.out(d, lambda x: ui.console.print(ui.kv_panel(bare(model), [("State", state_text(x.get("state")))] +
|
|
145
|
+
[(k, v) for k, v in x.items() if k != "state" and not isinstance(v, (dict, list))])))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@group.command("list", short_help="Deployments in the current context")
|
|
149
|
+
@click.option("--all", "show_all", is_flag=True, help="Include cancelled, expired and removed deployments.")
|
|
150
|
+
@pass_app
|
|
151
|
+
def list_(app: App, show_all: bool) -> None:
|
|
152
|
+
"""List deployments for the active context (organization, or personal with --personal)."""
|
|
153
|
+
auth(app)
|
|
154
|
+
d = app.api.get(base(app), org=False) or {}
|
|
155
|
+
rows = deployments_of(d)
|
|
156
|
+
shown = rows if show_all else [x for x in rows if x.get("status") in LIVE]
|
|
157
|
+
|
|
158
|
+
def render(_: Any) -> None:
|
|
159
|
+
if not shown:
|
|
160
|
+
ui.info(f"No {'' if show_all else 'active '}deployments for {owner_label(app)}. Deploy one with `ipx featherless deploy <model>`.")
|
|
161
|
+
return
|
|
162
|
+
ui.console.print(ui.make_table(
|
|
163
|
+
["ID", "Call as", "Status", "State", "Fee", "Renews"],
|
|
164
|
+
[(x.get("id"), x.get("display_model_id") or hf(str(x.get("featherless_model_id") or "")), status_text(x.get("status")), state_text(x.get("model_state")) if x.get("model_state") else "—",
|
|
165
|
+
money(x.get("monthly_fee")), date(x.get("billing_cycle_end"))) for x in shown],
|
|
166
|
+
title=f"Featherless deployments · {owner_label(app)} ({len(shown)})"))
|
|
167
|
+
|
|
168
|
+
app.out(d if show_all else {**meta_of(d), "data": shown, "total": len(shown)}, render)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@group.command("get", short_help="Show one deployment")
|
|
172
|
+
@click.argument("deployment_id")
|
|
173
|
+
@pass_app
|
|
174
|
+
def get(app: App, deployment_id: str) -> None:
|
|
175
|
+
"""Details of a deployment: pricing, billing cycle and model state."""
|
|
176
|
+
auth(app)
|
|
177
|
+
d = app.api.get(f"{base(app)}/{deployment_id}", org=False) or {}
|
|
178
|
+
app.out(d, show_deployment)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@group.command("deploy", short_help="Deploy a model", examples=["ipx featherless deploy Qwen/Qwen2.5-7B-Instruct", "ipx -O acme featherless deploy hf/Qwen/Qwen2.5-7B-Instruct -y"])
|
|
182
|
+
@click.argument("model")
|
|
183
|
+
@pass_app
|
|
184
|
+
def deploy(app: App, model: str) -> None:
|
|
185
|
+
"""Deploy MODEL (Author/Model) for $0.50/month, charged now to your wallet or the organization's."""
|
|
186
|
+
auth(app)
|
|
187
|
+
mid = hf(model)
|
|
188
|
+
state = None
|
|
189
|
+
try:
|
|
190
|
+
state = (app.api.get(f"/v1/deploy/model-status/{bare(mid)}", auth=False, org=False) or {}).get("state")
|
|
191
|
+
except IpxError:
|
|
192
|
+
pass
|
|
193
|
+
if not app.json:
|
|
194
|
+
ui.console.print(f"Deploying [bold]{bare(mid)}[/bold] for {owner_label(app)}"
|
|
195
|
+
+ (f" · currently [bold]{state}[/bold]" if state else "") + ".")
|
|
196
|
+
if state in ("cold", "loading"):
|
|
197
|
+
ui.warn("This model is not loaded yet; the first request can take 5-15 minutes.")
|
|
198
|
+
if not ui.confirm(f"Charge the $0.50 monthly fee to {owner_label(app)}?", default=False):
|
|
199
|
+
raise click.Abort()
|
|
200
|
+
d = app.api.post(base(app), {"model_id": mid}, org=False, timeout=180) or {}
|
|
201
|
+
|
|
202
|
+
def render(x: Dict[str, Any]) -> None:
|
|
203
|
+
ui.ok(x.get("message") or f"Deployed {bare(mid)}.")
|
|
204
|
+
if x.get("deployment"):
|
|
205
|
+
show_deployment(x["deployment"])
|
|
206
|
+
if x.get("disclaimer"):
|
|
207
|
+
ui.dim(x["disclaimer"])
|
|
208
|
+
|
|
209
|
+
app.out(d, render)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _action(name: str, verb: str, method: str, suffix: str, past: str, confirm: Optional[str] = None) -> None:
|
|
213
|
+
@group.command(name, short_help=f"{verb} a deployment")
|
|
214
|
+
@click.argument("deployment_id")
|
|
215
|
+
@pass_app
|
|
216
|
+
def _cmd(app: App, deployment_id: str) -> None:
|
|
217
|
+
auth(app)
|
|
218
|
+
if confirm and not ui.confirm(confirm.format(id=deployment_id, owner=owner_label(app))):
|
|
219
|
+
raise click.Abort()
|
|
220
|
+
r = app.api.request(method, f"{base(app)}/{deployment_id}{suffix}", json={} if method == "POST" else None, org=False)
|
|
221
|
+
d = r.json() if r.body else {}
|
|
222
|
+
app.out(d, lambda x: ui.ok((x or {}).get("message") or past))
|
|
223
|
+
|
|
224
|
+
_cmd.__doc__ = {
|
|
225
|
+
"cancel": "Stop renewing a deployment. Access continues until the end of the billing cycle.",
|
|
226
|
+
"restore": "Undo a cancel while the deployment is still in its paid cycle.",
|
|
227
|
+
"terminate": "End a deployment immediately. This cannot be undone and the fee is not refunded.",
|
|
228
|
+
}[name]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
_action("cancel", "Cancel", "POST", "/cancel", "Deployment cancelled.", "Cancel deployment {id} for {owner} at the end of its cycle?")
|
|
232
|
+
_action("restore", "Restore", "POST", "/uncancel", "Deployment restored.")
|
|
233
|
+
_action("terminate", "Terminate", "DELETE", "", "Deployment terminated.", "Terminate deployment {id} for {owner} right now? This cannot be undone.")
|
|
234
|
+
group.alias("uncancel", "restore")
|
|
235
|
+
group.alias("delete", "terminate")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@group.command("usage", short_help="Tokens and credits used by a deployment")
|
|
239
|
+
@click.argument("deployment_id")
|
|
240
|
+
@pass_app
|
|
241
|
+
def usage(app: App, deployment_id: str) -> None:
|
|
242
|
+
"""Token usage and credits charged for a deployment in its current history."""
|
|
243
|
+
auth(app)
|
|
244
|
+
d = app.api.get(f"{base(app)}/{deployment_id}/usage", org=False) or {}
|
|
245
|
+
app.out(d, lambda x: ui.console.print(ui.kv_panel(x.get("featherless_model_id") or "Deployment usage", [
|
|
246
|
+
("Call as", x.get("display_model_id")), ("Status", status_text(x.get("status"))),
|
|
247
|
+
("Input tokens", ui.fmt_num(x.get("total_input_tokens"))), ("Output tokens", ui.fmt_num(x.get("total_output_tokens"))),
|
|
248
|
+
("Credits charged", ui.fmt_credits(x.get("total_credits_charged"))),
|
|
249
|
+
("Cycle", f"{date(x.get('billing_cycle_start'))} → {date(x.get('billing_cycle_end'))}")])))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
COMMANDS = [group]
|
ipx/commands/gen.py
ADDED
ipx/commands/imports.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""ipx import - add Hugging Face Spaces and websites to the desktop app's library."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import colorsys
|
|
6
|
+
import html
|
|
7
|
+
import json
|
|
8
|
+
import random
|
|
9
|
+
import re
|
|
10
|
+
import uuid
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
from urllib.parse import urlparse
|
|
14
|
+
|
|
15
|
+
import click
|
|
16
|
+
|
|
17
|
+
from ipx import ui
|
|
18
|
+
from ipx.app import App, pass_app
|
|
19
|
+
from ipx.clickx import IpxGroup
|
|
20
|
+
from ipx.config import desktop_data_dir
|
|
21
|
+
from ipx.errors import ApiError, IpxError
|
|
22
|
+
from ipx.http import raw_request
|
|
23
|
+
|
|
24
|
+
EMOJIS = ["🌐", "🚀", "🧠", "🎨", "📚", "🛠️", "💡", "🔭", "🧪", "🎧", "🗺️", "⚡"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def sanitize(s: str) -> str:
|
|
28
|
+
return re.sub(r"[^a-zA-Z0-9]", "_", s)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def folder(kind: str) -> Path:
|
|
32
|
+
d = desktop_data_dir() / ("spaces" if kind == "space" else "websites")
|
|
33
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
return d
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def readable_color() -> str:
|
|
38
|
+
h, s, v = random.random(), random.uniform(0.45, 0.7), random.uniform(0.75, 0.95)
|
|
39
|
+
r, g, b = (int(c * 255) for c in colorsys.hsv_to_rgb(h, s, v))
|
|
40
|
+
return f"#{r:02x}{g:02x}{b:02x}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def read_imports(kind: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
44
|
+
out = []
|
|
45
|
+
for k in ("space", "website"):
|
|
46
|
+
if kind and k != kind:
|
|
47
|
+
continue
|
|
48
|
+
for p in sorted(folder(k).glob("*.import")):
|
|
49
|
+
try:
|
|
50
|
+
d = json.loads(p.read_text(encoding="utf-8"))
|
|
51
|
+
if isinstance(d, dict):
|
|
52
|
+
d["_file"] = str(p)
|
|
53
|
+
out.append(d)
|
|
54
|
+
except (OSError, ValueError):
|
|
55
|
+
continue
|
|
56
|
+
return out
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def fetch_title(url: str) -> Optional[str]:
|
|
60
|
+
try:
|
|
61
|
+
r = raw_request("GET", url, headers={"Accept": "text/html"}, timeout=8)
|
|
62
|
+
m = re.search(r"<title[^>]*>(.*?)</title>", r.text[:200_000], re.I | re.S) # type: ignore[union-attr]
|
|
63
|
+
return html.unescape(re.sub(r"\s+", " ", m.group(1)).strip()) if m else None
|
|
64
|
+
except IpxError:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@click.group("import", cls=IpxGroup, section="Local", short_help="Add Spaces and websites to the desktop app",
|
|
69
|
+
examples=["ipx import space black-forest-labs/FLUX.1-schnell", "ipx import website https://excalidraw.com --title Excalidraw",
|
|
70
|
+
"ipx import list", "ipx import rm https://excalidraw.com"])
|
|
71
|
+
def group() -> None:
|
|
72
|
+
"""Add Hugging Face Spaces and websites to the InferencePort desktop app.
|
|
73
|
+
|
|
74
|
+
Imports are written to the desktop app's library folder, so they show up under "Installed" next time the app
|
|
75
|
+
loads that page (restart the app if it is already open). They work without the app being installed, too: the
|
|
76
|
+
folder is created and picked up whenever you install it.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@group.command("space", examples=["ipx import space stabilityai/stable-diffusion"])
|
|
81
|
+
@click.argument("repo", metavar="USER/REPO")
|
|
82
|
+
@pass_app
|
|
83
|
+
def space(app: App, repo: str) -> None:
|
|
84
|
+
"""Import a Hugging Face Space."""
|
|
85
|
+
repo = re.sub(r"^https?://huggingface\.co/spaces/", "", repo).strip("/")
|
|
86
|
+
if repo.count("/") != 1:
|
|
87
|
+
raise IpxError("Use the form user/space-name.", "Example: black-forest-labs/FLUX.1-schnell")
|
|
88
|
+
author, name = repo.split("/")
|
|
89
|
+
try:
|
|
90
|
+
with ui.status("Reading Space metadata…"):
|
|
91
|
+
api = raw_request("GET", f"https://huggingface.co/api/spaces/{author}/{name}", timeout=15).json() or {} # type: ignore[union-attr]
|
|
92
|
+
except ApiError as e:
|
|
93
|
+
if e.status == 404:
|
|
94
|
+
raise IpxError(f"Space {repo} was not found on Hugging Face.")
|
|
95
|
+
raise
|
|
96
|
+
card = api.get("cardData") or {}
|
|
97
|
+
cfg = {
|
|
98
|
+
"type": "space",
|
|
99
|
+
"title": re.sub(r"\b\w", lambda m: m.group(0).upper(), re.sub(r"[-_]", " ", name)),
|
|
100
|
+
"author": author,
|
|
101
|
+
"emoji": card.get("emoji") or "🧠",
|
|
102
|
+
"background": f"linear-gradient(to right, {card.get('colorFrom') or '#3178c6'}, {card.get('colorTo') or '#90caf9'})",
|
|
103
|
+
"sdk": api.get("sdk") or "unknown",
|
|
104
|
+
"short_description": card.get("short_description") or "",
|
|
105
|
+
}
|
|
106
|
+
path = folder("space") / f"{sanitize(author)}____{sanitize(cfg['title'])}.import"
|
|
107
|
+
path.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
|
|
108
|
+
app.out({**cfg, "file": str(path)}, lambda _: ui.ok(f"Imported Space [bold]{repo}[/bold] → {path.name}"))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@group.command("website", examples=["ipx import website https://huggingface.co --title \"Hugging Face\""])
|
|
112
|
+
@click.argument("url")
|
|
113
|
+
@click.option("--title", help="Display name (default: the page title).")
|
|
114
|
+
@click.option("--emoji", help="Card emoji (default: random).")
|
|
115
|
+
@pass_app
|
|
116
|
+
def website(app: App, url: str, title: Optional[str], emoji: Optional[str]) -> None:
|
|
117
|
+
"""Import a website as an app card."""
|
|
118
|
+
if not url.startswith(("http://", "https://")):
|
|
119
|
+
url = "https://" + url
|
|
120
|
+
parsed = urlparse(url)
|
|
121
|
+
if not parsed.netloc:
|
|
122
|
+
raise IpxError("That does not look like a URL.", "Example: https://example.com")
|
|
123
|
+
existing = [i for i in read_imports("website") if i.get("url") == url]
|
|
124
|
+
if existing:
|
|
125
|
+
raise IpxError(f"{url} is already imported.", "Remove it first with `ipx import rm`.")
|
|
126
|
+
if not title:
|
|
127
|
+
with ui.status("Fetching page title…"):
|
|
128
|
+
title = fetch_title(url) or parsed.netloc
|
|
129
|
+
cfg = {
|
|
130
|
+
"type": "website", "title": title[:80], "author": "", "emoji": emoji or random.choice(EMOJIS),
|
|
131
|
+
"background": f"linear-gradient(to right, {readable_color()}, {readable_color()})",
|
|
132
|
+
"sdk": "unknown", "short_description": "", "url": url,
|
|
133
|
+
}
|
|
134
|
+
path = folder("website") / f"{uuid.uuid4()}.import"
|
|
135
|
+
path.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
|
|
136
|
+
app.out({**cfg, "file": str(path)}, lambda _: ui.ok(f"Imported website [bold]{title}[/bold] ({url})"))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@group.command("file", short_help="Import a shared .import file")
|
|
140
|
+
@click.argument("path", type=click.Path(exists=True, dir_okay=False))
|
|
141
|
+
@pass_app
|
|
142
|
+
def file_(app: App, path: str) -> None:
|
|
143
|
+
"""Import an `.import` file someone shared from the desktop app."""
|
|
144
|
+
try:
|
|
145
|
+
cfg = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
146
|
+
except ValueError:
|
|
147
|
+
raise IpxError("That file is not valid JSON.")
|
|
148
|
+
if not isinstance(cfg, dict) or cfg.get("type") not in ("space", "website") or not cfg.get("title"):
|
|
149
|
+
raise IpxError("Not an InferencePort import file.", "It must have a type of space or website, and a title.")
|
|
150
|
+
if cfg["type"] == "website" and not str(cfg.get("url", "")).startswith(("http://", "https://")):
|
|
151
|
+
raise IpxError("The website import has no valid URL.")
|
|
152
|
+
name = (f"{sanitize(cfg.get('author', ''))}____{sanitize(cfg['title'])}.import" if cfg["type"] == "space" else f"{uuid.uuid4()}.import")
|
|
153
|
+
dest = folder(cfg["type"]) / name
|
|
154
|
+
dest.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
|
|
155
|
+
app.out({"file": str(dest)}, lambda _: ui.ok(f"Imported [bold]{cfg['title']}[/bold]"))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@group.command("list", short_help="What is imported")
|
|
159
|
+
@click.option("--type", "kind", type=click.Choice(["space", "website"]))
|
|
160
|
+
@pass_app
|
|
161
|
+
def list_(app: App, kind: Optional[str]) -> None:
|
|
162
|
+
"""List imported Spaces and websites."""
|
|
163
|
+
items = read_imports(kind)
|
|
164
|
+
|
|
165
|
+
def render(_: Any) -> None:
|
|
166
|
+
if not items:
|
|
167
|
+
ui.info("Nothing imported yet. Try `ipx import space user/repo` or `ipx import website <url>`.")
|
|
168
|
+
return
|
|
169
|
+
rows = [(i.get("emoji"), i.get("type"), i.get("title"), (f"{i['author']}/…" if i.get("type") == "space" and i.get("author") else i.get("url")), i.get("sdk") if i.get("type") == "space" else "")
|
|
170
|
+
for i in items]
|
|
171
|
+
ui.console.print(ui.make_table(["", "Type", "Title", "Source", "SDK"], rows, title=f"Imports ({len(items)})"))
|
|
172
|
+
|
|
173
|
+
app.out([{k: v for k, v in i.items()} for i in items], render)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@group.command("rm", short_help="Remove an import")
|
|
177
|
+
@click.argument("target", metavar="URL|USER/REPO|TITLE")
|
|
178
|
+
@pass_app
|
|
179
|
+
def rm(app: App, target: str) -> None:
|
|
180
|
+
"""Remove an imported website (by URL or title) or Space (by user/repo or title)."""
|
|
181
|
+
t = target.strip().lower()
|
|
182
|
+
hits = []
|
|
183
|
+
for i in read_imports():
|
|
184
|
+
keys = {str(i.get("url", "")).lower(), str(i.get("title", "")).lower(), f"{i.get('author', '')}/{i.get('title', '')}".lower()}
|
|
185
|
+
if i.get("type") == "space":
|
|
186
|
+
keys.add(f"{i.get('author', '')}/{str(i.get('title', '')).replace(' ', '-')}".lower())
|
|
187
|
+
if t in keys:
|
|
188
|
+
hits.append(i)
|
|
189
|
+
if not hits:
|
|
190
|
+
raise IpxError(f"Nothing imported matches “{target}”.", "See `ipx import list`.")
|
|
191
|
+
if len(hits) > 1 and not ui.STATE["yes"]:
|
|
192
|
+
raise IpxError(f"{len(hits)} imports match “{target}”.", "Use the exact URL, or pass --yes to remove all of them.")
|
|
193
|
+
for i in hits:
|
|
194
|
+
Path(i["_file"]).unlink(missing_ok=True)
|
|
195
|
+
ui.ok(f"Removed [bold]{i.get('title')}[/bold]")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
COMMANDS = [group]
|
ipx/commands/local.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""ipx local - chat with and prompt models running on this machine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Any, Dict, Iterator, List, Optional
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from ipx import chat as chatmod
|
|
11
|
+
from ipx import ui
|
|
12
|
+
from ipx.app import App, pass_app
|
|
13
|
+
from ipx.clickx import IpxGroup
|
|
14
|
+
from ipx.errors import IpxError
|
|
15
|
+
from ipx.local import Ollama
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _backend(oll: Ollama):
|
|
19
|
+
def backend(messages: List[Dict[str, Any]], model: Optional[str]) -> Iterator[chatmod.Event]:
|
|
20
|
+
total = 0
|
|
21
|
+
for ev in oll.chat(model or "", messages):
|
|
22
|
+
m = ev.get("message") or {}
|
|
23
|
+
if m.get("thinking"):
|
|
24
|
+
yield "think", m["thinking"]
|
|
25
|
+
if m.get("content"):
|
|
26
|
+
yield "text", m["content"]
|
|
27
|
+
if ev.get("done"):
|
|
28
|
+
total = int(ev.get("prompt_eval_count") or 0) + int(ev.get("eval_count") or 0)
|
|
29
|
+
yield "usage", {"tokens": total}
|
|
30
|
+
|
|
31
|
+
return backend
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _pick_model(app: App, oll: Ollama, model: Optional[str]) -> str:
|
|
35
|
+
if model:
|
|
36
|
+
return model
|
|
37
|
+
default = app.cfg.get("default_local_model")
|
|
38
|
+
installed = [m["name"] for m in oll.list()]
|
|
39
|
+
if default and default in installed:
|
|
40
|
+
return default
|
|
41
|
+
if not installed:
|
|
42
|
+
raise IpxError("No local models installed.", "Pull one first: `ipx model pull llama3.2`.")
|
|
43
|
+
if len(installed) == 1:
|
|
44
|
+
return installed[0]
|
|
45
|
+
if ui.is_tty():
|
|
46
|
+
return ui.choose("Which model?", [(n, n) for n in installed])
|
|
47
|
+
raise IpxError("Several models are installed; specify one.", "Installed: " + ", ".join(installed))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@click.group("local", cls=IpxGroup, section="Local", short_help="Chat with local models",
|
|
51
|
+
examples=["ipx local chat llama3.2", "ipx local text llama3.2 \"Explain monads in one sentence\""])
|
|
52
|
+
def group() -> None:
|
|
53
|
+
"""Run chat and one-off prompts against models on this machine. Free, private and offline."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@group.command("chat")
|
|
57
|
+
@click.argument("model", required=False)
|
|
58
|
+
@click.option("--system", help="System prompt.")
|
|
59
|
+
@pass_app
|
|
60
|
+
def chat_(app: App, model: Optional[str], system: Optional[str]) -> None:
|
|
61
|
+
"""Interactive chat with a local model."""
|
|
62
|
+
oll = Ollama(app.cfg.ollama_url)
|
|
63
|
+
model = _pick_model(app, oll, model)
|
|
64
|
+
chatmod.run_repl(title="Local chat", subtitle="running on this machine · private", backend=_backend(oll), model=model,
|
|
65
|
+
system=system, list_models=lambda: [m["name"] for m in oll.list()])
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@group.command("text")
|
|
69
|
+
@click.argument("model")
|
|
70
|
+
@click.argument("prompt", required=False)
|
|
71
|
+
@click.option("--system", help="System prompt.")
|
|
72
|
+
@click.option("--stdin", "use_stdin", is_flag=True, help="Append standard input to the prompt.")
|
|
73
|
+
@pass_app
|
|
74
|
+
def text_(app: App, model: str, prompt: Optional[str], system: Optional[str], use_stdin: bool) -> None:
|
|
75
|
+
"""One-off generation. Pipe-friendly: the answer goes to stdout."""
|
|
76
|
+
parts = []
|
|
77
|
+
if use_stdin or prompt == "-":
|
|
78
|
+
parts.append(sys.stdin.read().strip())
|
|
79
|
+
if prompt and prompt != "-":
|
|
80
|
+
parts.append(prompt)
|
|
81
|
+
full = "\n\n".join(p for p in parts if p)
|
|
82
|
+
if not full:
|
|
83
|
+
raise IpxError("No prompt given.", "Pass it as an argument or pipe it in with `-`.")
|
|
84
|
+
oll = Ollama(app.cfg.ollama_url)
|
|
85
|
+
chatmod.one_shot(backend=_backend(oll), prompt=full, model=model, system=system, as_json=app.json)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
COMMANDS = [group]
|