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.
@@ -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
@@ -0,0 +1,3 @@
1
+ """ipx gen - Lightning, the subscription-backed API (see cloudgen for the shared definition)."""
2
+
3
+ from ipx.commands.cloudgen import gen_group as group # noqa: F401
@@ -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]