inferenceport-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ipx/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"]