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/auth.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Sign-in flows against Supabase (email/password, OAuth via browser, SSO) plus API-key login and token refresh."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import secrets
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import webbrowser
|
|
11
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
12
|
+
from typing import Any, Dict, Optional
|
|
13
|
+
|
|
14
|
+
from ipx.config import FALLBACK_SUPABASE, Config, Credentials
|
|
15
|
+
from ipx.errors import ApiError, IpxError
|
|
16
|
+
from ipx.http import raw_request
|
|
17
|
+
|
|
18
|
+
CALLBACK_PAGE = "https://inference.js.org/authcallback.html"
|
|
19
|
+
OAUTH_PROVIDERS = {"github": "GitHub", "google": "Google", "microsoft": "Microsoft", "huggingface": "Hugging Face"}
|
|
20
|
+
# Supabase's provider id for Microsoft is "azure".
|
|
21
|
+
PROVIDER_IDS = {"github": "github", "google": "google", "microsoft": "azure", "huggingface": "custom:huggingface"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# --------------------------------------------------------------------------- supabase config
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def supabase_config(cfg: Config) -> Dict[str, str]:
|
|
28
|
+
"""Supabase URL + publishable key. Fetched from /v1/config (public) and cached, with a baked-in fallback."""
|
|
29
|
+
cached = cfg.get("supabase")
|
|
30
|
+
if isinstance(cached, dict) and cached.get("url") and cached.get("publishableKey"):
|
|
31
|
+
return cached
|
|
32
|
+
try:
|
|
33
|
+
r = raw_request("GET", cfg.base_url + "/v1/config", timeout=15)
|
|
34
|
+
sb = r.json().get("supabase") or {} # type: ignore[union-attr]
|
|
35
|
+
if sb.get("url") and sb.get("publishableKey"):
|
|
36
|
+
found = {"url": sb["url"].rstrip("/"), "publishableKey": sb["publishableKey"]}
|
|
37
|
+
cfg.set("supabase", found)
|
|
38
|
+
return found
|
|
39
|
+
except IpxError:
|
|
40
|
+
pass
|
|
41
|
+
return dict(FALLBACK_SUPABASE)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _auth_call(cfg: Config, method: str, path: str, body: Any = None, token: Optional[str] = None) -> Dict[str, Any]:
|
|
45
|
+
sb = supabase_config(cfg)
|
|
46
|
+
headers = {"apikey": sb["publishableKey"], "Authorization": f"Bearer {token or sb['publishableKey']}"}
|
|
47
|
+
try:
|
|
48
|
+
r = raw_request(method, sb["url"] + path, json=body, headers=headers, timeout=30)
|
|
49
|
+
except ApiError as e:
|
|
50
|
+
raise IpxError(_friendly_auth_error(e), "Check your credentials, or sign in with `ipx auth login --provider github`.")
|
|
51
|
+
return r.json() or {} # type: ignore[union-attr]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _friendly_auth_error(e: ApiError) -> str:
|
|
55
|
+
msg = e.message
|
|
56
|
+
low = msg.lower()
|
|
57
|
+
if "invalid login" in low or "invalid_grant" in low or "invalid credentials" in low:
|
|
58
|
+
return "Incorrect email or password."
|
|
59
|
+
if "email not confirmed" in low:
|
|
60
|
+
return "Your email address is not verified yet. Check your inbox for the confirmation link."
|
|
61
|
+
return msg
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# --------------------------------------------------------------------------- flows
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def password_login(cfg: Config, creds: Credentials, email: str, password: str) -> Dict[str, Any]:
|
|
68
|
+
session = _auth_call(cfg, "POST", "/auth/v1/token?grant_type=password", {"email": email, "password": password})
|
|
69
|
+
creds.store_session(session, email=email)
|
|
70
|
+
return session
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def refresh(cfg: Config, creds: Credentials) -> bool:
|
|
74
|
+
"""Exchange the stored refresh token for a new session. Returns True on success."""
|
|
75
|
+
rt = creds.refresh_token
|
|
76
|
+
if not rt or creds.data.get("auth_type") != "jwt":
|
|
77
|
+
return False
|
|
78
|
+
try:
|
|
79
|
+
session = _auth_call(cfg, "POST", "/auth/v1/token?grant_type=refresh_token", {"refresh_token": rt})
|
|
80
|
+
except IpxError:
|
|
81
|
+
return False
|
|
82
|
+
if not session.get("access_token"):
|
|
83
|
+
return False
|
|
84
|
+
creds.store_session(session)
|
|
85
|
+
return True
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def fetch_user(cfg: Config, access_token: str) -> Dict[str, Any]:
|
|
89
|
+
return _auth_call(cfg, "GET", "/auth/v1/user", token=access_token)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def oauth_url(cfg: Config, provider: str, redirect_to: str) -> str:
|
|
93
|
+
sb = supabase_config(cfg)
|
|
94
|
+
params = {"provider": PROVIDER_IDS[provider], "redirect_to": redirect_to}
|
|
95
|
+
if provider == "microsoft":
|
|
96
|
+
params["scope"] = "openid profile email"
|
|
97
|
+
q = urllib.parse.urlencode(params)
|
|
98
|
+
return f"{sb['url']}/auth/v1/authorize?{q}"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def sso_url(cfg: Config, email: str, redirect_to: str) -> str:
|
|
102
|
+
"""Discover the org SSO connection for an email domain and ask Supabase for the IdP login URL."""
|
|
103
|
+
disc = raw_request("POST", cfg.base_url + "/v1/orgs/sso/discover", json={"email": email}, timeout=15).json() # type: ignore[union-attr]
|
|
104
|
+
if not disc or not disc.get("sso"):
|
|
105
|
+
raise IpxError("No single sign-on is configured for this email domain.",
|
|
106
|
+
"Use `ipx auth login` (email/password) or `--provider`.")
|
|
107
|
+
target: Dict[str, Any] = {"provider_id": disc["provider_id"]} if disc.get("provider_id") else {"domain": disc.get("domain")}
|
|
108
|
+
if not target.get("provider_id") and not target.get("domain"):
|
|
109
|
+
raise IpxError("Single sign-on is configured but incomplete. Contact your administrator.")
|
|
110
|
+
body = dict(target, redirect_to=redirect_to, skip_http_redirect=True)
|
|
111
|
+
res = _auth_call(cfg, "POST", "/auth/v1/sso", body)
|
|
112
|
+
if not res.get("url"):
|
|
113
|
+
raise IpxError("Could not start single sign-on.")
|
|
114
|
+
return str(res["url"])
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def api_key_login(cfg: Config, creds: Credentials, key: str) -> Dict[str, Any]:
|
|
118
|
+
"""Validate a Lightning API key (ipa_live_...) against /v1/me and store it."""
|
|
119
|
+
key = key.strip()
|
|
120
|
+
try:
|
|
121
|
+
me = raw_request("GET", cfg.base_url + "/v1/me",
|
|
122
|
+
headers={"Authorization": f"Bearer {key}", "X-Client-ID": cfg.client_id}, timeout=20).json() # type: ignore[union-attr]
|
|
123
|
+
except ApiError as e:
|
|
124
|
+
if e.status in (401, 403):
|
|
125
|
+
raise IpxError("That API key was rejected.", "Create one at https://console.inferenceport.ai or with `ipx p2g keys create`.")
|
|
126
|
+
raise
|
|
127
|
+
email = ((me or {}).get("identity") or {}).get("email")
|
|
128
|
+
creds.store_api_key(key, email=email)
|
|
129
|
+
return me or {}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def parse_callback(text: str) -> Dict[str, Any]:
|
|
133
|
+
"""Parse tokens from a pasted callback URL or a bare `access_token=...&refresh_token=...` string."""
|
|
134
|
+
text = text.strip()
|
|
135
|
+
frag = text.split("#", 1)[1] if "#" in text else (text.split("?", 1)[1] if "?" in text and "access_token" in text else text)
|
|
136
|
+
params = {k: v[0] for k, v in urllib.parse.parse_qs(frag).items()}
|
|
137
|
+
if params.get("error"):
|
|
138
|
+
raise IpxError(params.get("error_description") or params["error"])
|
|
139
|
+
if not params.get("access_token"):
|
|
140
|
+
raise IpxError("No access token found in what you pasted.", "Paste the full address from the browser's address bar.")
|
|
141
|
+
session: Dict[str, Any] = {
|
|
142
|
+
"access_token": params["access_token"],
|
|
143
|
+
"refresh_token": params.get("refresh_token"),
|
|
144
|
+
}
|
|
145
|
+
if params.get("expires_at"):
|
|
146
|
+
session["expires_at"] = float(params["expires_at"])
|
|
147
|
+
elif params.get("expires_in"):
|
|
148
|
+
session["expires_at"] = time.time() + float(params["expires_in"])
|
|
149
|
+
return session
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
_LANDING = """<!doctype html><meta charset=utf-8><title>ipx</title>
|
|
153
|
+
<style>body{margin:0;display:grid;place-items:center;height:100vh;background:#0b1020;color:#e2e8f0;font:16px system-ui}
|
|
154
|
+
.c{text-align:center;padding:40px 48px;border:1px solid #1e293b;border-radius:16px;background:#0f172a}
|
|
155
|
+
h1{margin:0 0 8px;font-size:22px;background:linear-gradient(90deg,#4f7cff,#a78bfa);-webkit-background-clip:text;color:transparent}
|
|
156
|
+
p{margin:0;color:#94a3b8}</style>
|
|
157
|
+
<div class=c><h1 id=t>Signing you in…</h1><p id=m>One moment.</p></div>
|
|
158
|
+
<script>
|
|
159
|
+
const h=location.hash.slice(1);
|
|
160
|
+
fetch('/token?n=__NONCE__',{method:'POST',body:h}).then(r=>{
|
|
161
|
+
document.getElementById('t').textContent=r.ok?'You are signed in to ipx':'Sign-in failed';
|
|
162
|
+
document.getElementById('m').textContent=r.ok?'You can close this tab and return to your terminal.':'Return to your terminal for details.';
|
|
163
|
+
}).catch(()=>{document.getElementById('t').textContent='Sign-in failed';});
|
|
164
|
+
</script>"""
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def browser_login(cfg: Config, build_url, *, timeout: float = 300.0, open_browser: bool = True,
|
|
168
|
+
on_url=None) -> Dict[str, Any]:
|
|
169
|
+
"""Run the OAuth/SSO dance with a loopback listener.
|
|
170
|
+
|
|
171
|
+
The hosted callback page (inference.js.org/authcallback.html) forwards the token fragment to the `rd`
|
|
172
|
+
address we give it; a tiny page served from 127.0.0.1 reads the fragment and posts it back to us.
|
|
173
|
+
"""
|
|
174
|
+
nonce = secrets.token_urlsafe(16)
|
|
175
|
+
result: Dict[str, Any] = {}
|
|
176
|
+
done = threading.Event()
|
|
177
|
+
|
|
178
|
+
class Handler(BaseHTTPRequestHandler):
|
|
179
|
+
def log_message(self, *a: Any) -> None: # silence
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
def do_GET(self) -> None: # noqa: N802
|
|
183
|
+
if urllib.parse.urlparse(self.path).path == "/callback":
|
|
184
|
+
body = _LANDING.replace("__NONCE__", nonce).encode()
|
|
185
|
+
self.send_response(200)
|
|
186
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
187
|
+
self.send_header("Content-Length", str(len(body)))
|
|
188
|
+
self.end_headers()
|
|
189
|
+
self.wfile.write(body)
|
|
190
|
+
else:
|
|
191
|
+
self.send_response(404)
|
|
192
|
+
self.end_headers()
|
|
193
|
+
|
|
194
|
+
def do_POST(self) -> None: # noqa: N802
|
|
195
|
+
q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
|
|
196
|
+
ok = urllib.parse.urlparse(self.path).path == "/token" and q.get("n", [""])[0] == nonce
|
|
197
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
198
|
+
raw = self.rfile.read(length).decode("utf-8", "replace") if length else ""
|
|
199
|
+
self.send_response(200 if ok else 403)
|
|
200
|
+
self.end_headers()
|
|
201
|
+
if ok:
|
|
202
|
+
try:
|
|
203
|
+
result["session"] = parse_callback(raw)
|
|
204
|
+
except IpxError as e:
|
|
205
|
+
result["error"] = e
|
|
206
|
+
done.set()
|
|
207
|
+
|
|
208
|
+
server = HTTPServer(("127.0.0.1", 0), Handler)
|
|
209
|
+
port = server.server_address[1]
|
|
210
|
+
rd = f"http://127.0.0.1:{port}/callback"
|
|
211
|
+
redirect_to = f"{CALLBACK_PAGE}?rd={urllib.parse.quote(rd, safe='')}"
|
|
212
|
+
url = build_url(redirect_to)
|
|
213
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
214
|
+
thread.start()
|
|
215
|
+
try:
|
|
216
|
+
if on_url:
|
|
217
|
+
on_url(url)
|
|
218
|
+
if open_browser:
|
|
219
|
+
try:
|
|
220
|
+
webbrowser.open(url)
|
|
221
|
+
except Exception: # pragma: no cover
|
|
222
|
+
pass
|
|
223
|
+
if not done.wait(timeout):
|
|
224
|
+
raise IpxError("Timed out waiting for the browser sign-in.",
|
|
225
|
+
"Try again, or use `ipx auth login --provider <name> --paste` to paste the redirect URL.")
|
|
226
|
+
finally:
|
|
227
|
+
server.shutdown()
|
|
228
|
+
server.server_close()
|
|
229
|
+
if "error" in result:
|
|
230
|
+
raise result["error"]
|
|
231
|
+
return result["session"]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def complete_session(cfg: Config, creds: Credentials, session: Dict[str, Any]) -> Dict[str, Any]:
|
|
235
|
+
"""Look up the user for a freshly obtained session and persist it."""
|
|
236
|
+
user = fetch_user(cfg, session["access_token"])
|
|
237
|
+
session = dict(session, user=user)
|
|
238
|
+
creds.store_session(session, email=user.get("email"))
|
|
239
|
+
return user
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def sign_out(cfg: Config, creds: Credentials) -> None:
|
|
243
|
+
token = creds.access_token if creds.data.get("auth_type") == "jwt" else None
|
|
244
|
+
if token:
|
|
245
|
+
try:
|
|
246
|
+
_auth_call(cfg, "POST", "/auth/v1/logout", {}, token=token)
|
|
247
|
+
except IpxError:
|
|
248
|
+
pass # revoking server-side is best effort; local credentials are always removed
|
|
249
|
+
creds.clear()
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def decode_jwt_claims(token: str) -> Dict[str, Any]:
|
|
253
|
+
import base64
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
payload = token.split(".")[1]
|
|
257
|
+
payload += "=" * (-len(payload) % 4)
|
|
258
|
+
return json.loads(base64.urlsafe_b64decode(payload))
|
|
259
|
+
except Exception:
|
|
260
|
+
return {}
|
ipx/chat.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Streaming chat: the interactive REPL and one-shot text generation, backend-agnostic.
|
|
2
|
+
|
|
3
|
+
A backend is any callable ``stream(messages, model) -> Iterator[event]`` where an event is one of
|
|
4
|
+
("text", str) a content delta
|
|
5
|
+
("think", str) a reasoning delta (shown dimmed)
|
|
6
|
+
("meta", dict) e.g. {"model_name": "..."} from the Lightning router
|
|
7
|
+
("usage", dict) {"credits": float, "tokens": int, ...} at the end of the stream
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple
|
|
17
|
+
|
|
18
|
+
from rich.console import Group
|
|
19
|
+
from rich.live import Live
|
|
20
|
+
from rich.markdown import Markdown
|
|
21
|
+
from rich.panel import Panel
|
|
22
|
+
from rich.text import Text
|
|
23
|
+
|
|
24
|
+
from ipx import ui
|
|
25
|
+
from ipx.errors import IpxError
|
|
26
|
+
|
|
27
|
+
Event = Tuple[str, Any]
|
|
28
|
+
Backend = Callable[[List[Dict[str, Any]], Optional[str]], Iterator[Event]]
|
|
29
|
+
|
|
30
|
+
HELP = [
|
|
31
|
+
("/help", "show this help"),
|
|
32
|
+
("/exit, /quit", "leave the chat (or press Ctrl+D)"),
|
|
33
|
+
("/clear", "forget the conversation so far"),
|
|
34
|
+
("/system <text>", "set the system prompt (clears history)"),
|
|
35
|
+
("/model [name]", "show or switch the model"),
|
|
36
|
+
("/models", "list models you can use"),
|
|
37
|
+
("/retry", "regenerate the last answer"),
|
|
38
|
+
("/save [file]", "save the transcript as Markdown"),
|
|
39
|
+
("/usage", "tokens and credits used in this chat"),
|
|
40
|
+
('"""', "start/end a multi-line message (or end a line with \\)"),
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Totals:
|
|
45
|
+
def __init__(self) -> None:
|
|
46
|
+
self.credits = 0.0
|
|
47
|
+
self.tokens = 0
|
|
48
|
+
self.turns = 0
|
|
49
|
+
|
|
50
|
+
def add(self, u: Dict[str, Any]) -> None:
|
|
51
|
+
self.credits += float(u.get("credits") or 0)
|
|
52
|
+
self.tokens += int(u.get("tokens") or 0)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _stream_turn(backend: Backend, messages: List[Dict[str, Any]], model: Optional[str], totals: Totals,
|
|
56
|
+
live_render: bool = True) -> Tuple[str, Dict[str, Any]]:
|
|
57
|
+
"""Run one assistant turn, rendering Markdown live. Returns (text, info)."""
|
|
58
|
+
text, think = "", ""
|
|
59
|
+
info: Dict[str, Any] = {}
|
|
60
|
+
start = time.time()
|
|
61
|
+
first = None
|
|
62
|
+
|
|
63
|
+
def view() -> Group:
|
|
64
|
+
parts: List[Any] = []
|
|
65
|
+
if think:
|
|
66
|
+
tail = think.strip().splitlines()[-3:]
|
|
67
|
+
parts.append(Text("💭 " + " ".join(tail)[:300], style="dim italic"))
|
|
68
|
+
if text:
|
|
69
|
+
parts.append(Markdown(text, code_theme="monokai"))
|
|
70
|
+
elif not think:
|
|
71
|
+
parts.append(Text("●●●", style=ui.BRAND))
|
|
72
|
+
return Group(*parts)
|
|
73
|
+
|
|
74
|
+
stream = backend(messages, model)
|
|
75
|
+
try:
|
|
76
|
+
with Live(view(), console=ui.console, refresh_per_second=14, transient=False, vertical_overflow="visible") as live:
|
|
77
|
+
try:
|
|
78
|
+
for kind, payload in stream:
|
|
79
|
+
if kind == "text":
|
|
80
|
+
first = first or time.time()
|
|
81
|
+
text += payload
|
|
82
|
+
elif kind == "think":
|
|
83
|
+
think += payload
|
|
84
|
+
elif kind == "meta":
|
|
85
|
+
info.update(payload)
|
|
86
|
+
elif kind == "usage":
|
|
87
|
+
info.update(payload)
|
|
88
|
+
totals.add(payload)
|
|
89
|
+
live.update(view())
|
|
90
|
+
except KeyboardInterrupt:
|
|
91
|
+
info["interrupted"] = True
|
|
92
|
+
live.update(Group(Markdown(text, code_theme="monokai")) if text else Text(""))
|
|
93
|
+
finally:
|
|
94
|
+
close = getattr(stream, "close", None)
|
|
95
|
+
if close:
|
|
96
|
+
close()
|
|
97
|
+
info["elapsed"] = time.time() - start
|
|
98
|
+
return text, info
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _footer(info: Dict[str, Any], model: Optional[str], to=None) -> None:
|
|
102
|
+
out = to or ui.console
|
|
103
|
+
bits = []
|
|
104
|
+
used = info.get("model_name") or model
|
|
105
|
+
if used:
|
|
106
|
+
bits.append(str(used))
|
|
107
|
+
if info.get("tokens"):
|
|
108
|
+
bits.append(f"{info['tokens']} tok")
|
|
109
|
+
if info.get("credits"):
|
|
110
|
+
bits.append(f"{ui.fmt_credits(info['credits'])} credits")
|
|
111
|
+
if info.get("elapsed"):
|
|
112
|
+
bits.append(f"{info['elapsed']:.1f}s")
|
|
113
|
+
if info.get("interrupted"):
|
|
114
|
+
bits.append("stopped")
|
|
115
|
+
if bits:
|
|
116
|
+
out.print(Text(" " + " · ".join(bits), style="dim"))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _read_message() -> Optional[str]:
|
|
120
|
+
"""Read a (possibly multi-line) message. Returns None on EOF."""
|
|
121
|
+
try:
|
|
122
|
+
line = ui.console.input("[user]you[/user] [muted]›[/muted] ")
|
|
123
|
+
except EOFError:
|
|
124
|
+
return None
|
|
125
|
+
if line.strip() == '"""':
|
|
126
|
+
lines = []
|
|
127
|
+
while True:
|
|
128
|
+
try:
|
|
129
|
+
nxt = ui.console.input("[muted]… [/muted]")
|
|
130
|
+
except EOFError:
|
|
131
|
+
break
|
|
132
|
+
if nxt.strip() == '"""':
|
|
133
|
+
break
|
|
134
|
+
lines.append(nxt)
|
|
135
|
+
return "\n".join(lines)
|
|
136
|
+
while line.endswith("\\"):
|
|
137
|
+
try:
|
|
138
|
+
line = line[:-1] + "\n" + ui.console.input("[muted]… [/muted]")
|
|
139
|
+
except EOFError:
|
|
140
|
+
break
|
|
141
|
+
return line
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def run_repl(*, title: str, subtitle: str, backend: Backend, model: Optional[str], system: Optional[str] = None,
|
|
145
|
+
list_models: Optional[Callable[[], List[str]]] = None, footer_note: Optional[str] = None) -> None:
|
|
146
|
+
if not ui.is_tty():
|
|
147
|
+
raise IpxError("Interactive chat needs a terminal.", "For scripts use the one-shot `text` command, e.g. `echo hi | ipx gen text`.")
|
|
148
|
+
totals = Totals()
|
|
149
|
+
messages: List[Dict[str, Any]] = []
|
|
150
|
+
if system:
|
|
151
|
+
messages.append({"role": "system", "content": system})
|
|
152
|
+
|
|
153
|
+
ui.console.print()
|
|
154
|
+
ui.console.print(Panel(
|
|
155
|
+
Text.assemble((title, "bold white"), (" " + subtitle, "muted"), "\n",
|
|
156
|
+
("model ", "muted"), (model or "auto", f"bold {ui.ACCENT}"),
|
|
157
|
+
(" · /help for commands · Ctrl+D to exit", "muted")),
|
|
158
|
+
border_style=ui.DIM, padding=(0, 2)))
|
|
159
|
+
if footer_note:
|
|
160
|
+
ui.dim(footer_note)
|
|
161
|
+
ui.console.print()
|
|
162
|
+
|
|
163
|
+
while True:
|
|
164
|
+
try:
|
|
165
|
+
msg = _read_message()
|
|
166
|
+
except KeyboardInterrupt:
|
|
167
|
+
ui.console.print()
|
|
168
|
+
break
|
|
169
|
+
if msg is None:
|
|
170
|
+
ui.console.print()
|
|
171
|
+
break
|
|
172
|
+
msg = msg.strip()
|
|
173
|
+
if not msg:
|
|
174
|
+
continue
|
|
175
|
+
if msg.startswith("/"):
|
|
176
|
+
cmd, _, arg = msg.partition(" ")
|
|
177
|
+
cmd, arg = cmd.lower(), arg.strip()
|
|
178
|
+
if cmd in ("/exit", "/quit", "/q"):
|
|
179
|
+
break
|
|
180
|
+
if cmd == "/help":
|
|
181
|
+
for k, v in HELP:
|
|
182
|
+
ui.console.print(f" [key]{k:<16}[/key] [muted]{v}[/muted]")
|
|
183
|
+
elif cmd == "/clear":
|
|
184
|
+
messages = [m for m in messages if m["role"] == "system"]
|
|
185
|
+
ui.ok("Conversation cleared.")
|
|
186
|
+
elif cmd == "/system":
|
|
187
|
+
messages = [{"role": "system", "content": arg}] if arg else []
|
|
188
|
+
ui.ok("System prompt updated; history cleared." if arg else "System prompt removed.")
|
|
189
|
+
elif cmd == "/model":
|
|
190
|
+
if arg:
|
|
191
|
+
model = arg
|
|
192
|
+
ui.ok(f"Model set to [bold]{model}[/bold]")
|
|
193
|
+
else:
|
|
194
|
+
ui.console.print(f"Model: [bold]{model or 'auto'}[/bold]")
|
|
195
|
+
elif cmd == "/models":
|
|
196
|
+
if list_models:
|
|
197
|
+
try:
|
|
198
|
+
for m in list_models():
|
|
199
|
+
ui.console.print(f" [accent]•[/accent] {m}")
|
|
200
|
+
except IpxError as e:
|
|
201
|
+
ui.render_error(e)
|
|
202
|
+
else:
|
|
203
|
+
ui.dim("Model listing is not available here.")
|
|
204
|
+
elif cmd == "/usage":
|
|
205
|
+
ui.console.print(f" {totals.turns} turns · {totals.tokens} tokens · {ui.fmt_credits(totals.credits)} credits")
|
|
206
|
+
elif cmd == "/save":
|
|
207
|
+
path = arg or f"chat-{datetime.now():%Y%m%d-%H%M%S}.md"
|
|
208
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
209
|
+
for m in messages:
|
|
210
|
+
who = {"user": "You", "assistant": "Assistant", "system": "System"}.get(m["role"], m["role"])
|
|
211
|
+
fh.write(f"## {who}\n\n{m['content']}\n\n")
|
|
212
|
+
ui.ok(f"Saved transcript to [bold]{path}[/bold]")
|
|
213
|
+
elif cmd == "/retry":
|
|
214
|
+
while messages and messages[-1]["role"] == "assistant":
|
|
215
|
+
messages.pop()
|
|
216
|
+
if not messages or messages[-1]["role"] != "user":
|
|
217
|
+
ui.warn("Nothing to retry yet.")
|
|
218
|
+
else:
|
|
219
|
+
_answer(backend, messages, model, totals)
|
|
220
|
+
else:
|
|
221
|
+
ui.warn(f"Unknown command {cmd}. Type /help.")
|
|
222
|
+
continue
|
|
223
|
+
messages.append({"role": "user", "content": msg})
|
|
224
|
+
if not _answer(backend, messages, model, totals):
|
|
225
|
+
messages.pop()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _answer(backend: Backend, messages: List[Dict[str, Any]], model: Optional[str], totals: Totals) -> bool:
|
|
229
|
+
ui.console.print(Text("assistant", style="bot"))
|
|
230
|
+
try:
|
|
231
|
+
text, info = _stream_turn(backend, messages, model, totals)
|
|
232
|
+
except IpxError as e:
|
|
233
|
+
ui.render_error(e)
|
|
234
|
+
return False
|
|
235
|
+
if text or info.get("interrupted"):
|
|
236
|
+
messages.append({"role": "assistant", "content": text})
|
|
237
|
+
totals.turns += 1
|
|
238
|
+
_footer(info, model)
|
|
239
|
+
ui.console.print()
|
|
240
|
+
return True
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def one_shot(*, backend: Backend, prompt: str, model: Optional[str], system: Optional[str], as_json: bool,
|
|
244
|
+
stream_out: bool = True) -> None:
|
|
245
|
+
"""Generate once. Streams plain text to stdout (pipe friendly); footer details go to stderr."""
|
|
246
|
+
messages: List[Dict[str, Any]] = []
|
|
247
|
+
if system:
|
|
248
|
+
messages.append({"role": "system", "content": system})
|
|
249
|
+
messages.append({"role": "user", "content": prompt})
|
|
250
|
+
out, info, totals = [], {}, Totals()
|
|
251
|
+
start = time.time()
|
|
252
|
+
for kind, payload in backend(messages, model):
|
|
253
|
+
if kind == "text":
|
|
254
|
+
out.append(payload)
|
|
255
|
+
if stream_out and not as_json:
|
|
256
|
+
sys.stdout.write(payload)
|
|
257
|
+
sys.stdout.flush()
|
|
258
|
+
elif kind == "meta":
|
|
259
|
+
info.update(payload)
|
|
260
|
+
elif kind == "usage":
|
|
261
|
+
info.update(payload)
|
|
262
|
+
totals.add(payload)
|
|
263
|
+
text = "".join(out)
|
|
264
|
+
if as_json:
|
|
265
|
+
ui.emit_json({"text": text, "model": info.get("model_name") or model, "usage": info})
|
|
266
|
+
return
|
|
267
|
+
if stream_out:
|
|
268
|
+
if not text.endswith("\n"):
|
|
269
|
+
sys.stdout.write("\n")
|
|
270
|
+
else:
|
|
271
|
+
sys.stdout.write(text + ("" if text.endswith("\n") else "\n"))
|
|
272
|
+
if not ui.STATE["quiet"] and sys.stderr.isatty():
|
|
273
|
+
info["elapsed"] = time.time() - start
|
|
274
|
+
_footer(info, model, to=ui.err)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def parse_openai_sse(stream) -> Iterator[Event]:
|
|
278
|
+
"""Translate an OpenAI-style (plus InferencePort extras) SSE stream into chat events."""
|
|
279
|
+
for event, data in stream.iter_sse():
|
|
280
|
+
if event == "payg-usage":
|
|
281
|
+
try:
|
|
282
|
+
u = json.loads(data)
|
|
283
|
+
except ValueError:
|
|
284
|
+
continue
|
|
285
|
+
yield "usage", {
|
|
286
|
+
"credits": u.get("payg_credits_charged"),
|
|
287
|
+
"tokens": u.get("payg_total_tokens"),
|
|
288
|
+
"input_tokens": u.get("payg_input_tokens"),
|
|
289
|
+
"output_tokens": u.get("payg_output_tokens"),
|
|
290
|
+
}
|
|
291
|
+
continue
|
|
292
|
+
if data.strip() == "[DONE]":
|
|
293
|
+
break
|
|
294
|
+
try:
|
|
295
|
+
obj = json.loads(data)
|
|
296
|
+
except ValueError:
|
|
297
|
+
continue
|
|
298
|
+
if not isinstance(obj, dict):
|
|
299
|
+
continue
|
|
300
|
+
if obj.get("error"):
|
|
301
|
+
err = obj["error"]
|
|
302
|
+
raise IpxError(err if isinstance(err, str) else err.get("message", "The model returned an error."))
|
|
303
|
+
rm = obj.get("router_metadata")
|
|
304
|
+
if isinstance(rm, dict):
|
|
305
|
+
yield "meta", {"model_name": rm.get("model_name")}
|
|
306
|
+
usage = obj.get("usage")
|
|
307
|
+
if isinstance(usage, dict) and usage.get("total_tokens"):
|
|
308
|
+
yield "usage", {"tokens": usage.get("total_tokens"), "credits": usage.get("payg_credits_charged")}
|
|
309
|
+
for choice in obj.get("choices") or []:
|
|
310
|
+
delta = choice.get("delta") or choice.get("message") or {}
|
|
311
|
+
if delta.get("reasoning_content"):
|
|
312
|
+
yield "think", delta["reasoning_content"]
|
|
313
|
+
if delta.get("content"):
|
|
314
|
+
yield "text", delta["content"]
|