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/cli.py ADDED
@@ -0,0 +1,142 @@
1
+ """Root command: global options, command registration and top-level error handling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import Optional
7
+
8
+ import click
9
+
10
+ from ipx import __version__, ui
11
+ from ipx.app import App
12
+ from ipx.clickx import IpxGroup
13
+ from ipx.errors import IpxError
14
+
15
+
16
+ @click.group(cls=IpxGroup, context_settings={"help_option_names": ["-h", "--help"], "max_content_width": 110},
17
+ help="""The InferencePort AI command line.
18
+
19
+ Chat with cloud and local models, generate images, video, audio and 3D, manage organizations, host a model
20
+ server and keep your models and imports in sync with the desktop app — all from one binary.""",
21
+ examples=[
22
+ "ipx auth login",
23
+ "ipx gen chat",
24
+ "ipx p2g image \"a lighthouse at dawn\" -o lighthouse.jpg",
25
+ "ipx model pull llama3.2 && ipx local chat llama3.2",
26
+ "ipx auth context acme && ipx org members list",
27
+ "ipx shield scan -f prompt.txt --fail-on review",
28
+ "ipx featherless deploy Qwen/Qwen2.5-7B-Instruct",
29
+ "ipx agent chat acme-bot/support",
30
+ "ipx mcp council reason \"…\" --tier basic",
31
+ "ipx usage get",
32
+ ])
33
+ @click.option("--json", "json_out", is_flag=True, envvar="IPX_JSON", help="Machine-readable JSON output.")
34
+ @click.option("-O", "--org", help="Run this command in an organization (slug), overriding the saved context.")
35
+ @click.option("--personal", is_flag=True, help="Ignore the organization context for this command.")
36
+ @click.option("--base-url", envvar="IPX_BASE_URL", help="API endpoint (default https://api.inferenceport.ai).")
37
+ @click.option("-q", "--quiet", is_flag=True, help="Only print results and errors.")
38
+ @click.option("-y", "--yes", is_flag=True, help="Assume yes for confirmation prompts.")
39
+ @click.option("--no-color", is_flag=True, help="Disable colour output.")
40
+ @click.version_option(__version__, "-V", "--version", prog_name="ipx", message="ipx %(version)s")
41
+ @click.pass_context
42
+ def root(ctx: click.Context, json_out: bool, org: Optional[str], personal: bool, base_url: Optional[str],
43
+ quiet: bool, yes: bool, no_color: bool) -> None:
44
+ ui.configure(json_out=json_out, quiet=quiet, yes=yes, no_color=no_color)
45
+ ctx.obj = App(json_out=json_out, org=org, personal=personal, base_url=base_url)
46
+
47
+
48
+ def _register() -> None:
49
+ from ipx.commands import (
50
+ agent,
51
+ auth,
52
+ config_cmd,
53
+ featherless,
54
+ gen,
55
+ imports,
56
+ local,
57
+ mcp,
58
+ misc,
59
+ model,
60
+ org,
61
+ p2g,
62
+ server,
63
+ shield,
64
+ usage,
65
+ )
66
+
67
+ for mod in (auth, org, usage, p2g, gen, shield, featherless, agent, mcp, model, local, server, imports, config_cmd, misc):
68
+ for cmd in mod.COMMANDS if hasattr(mod, "COMMANDS") else [mod.group]:
69
+ root.add_command(cmd)
70
+
71
+
72
+ def _prepare_streams() -> None:
73
+ for stream in (sys.stdout, sys.stderr):
74
+ try:
75
+ stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
76
+ except Exception:
77
+ pass
78
+
79
+
80
+ _HOIST_FLAGS = {"--json", "--no-color", "--personal", "--yes", "-y", "--quiet"}
81
+ _HOIST_VALUE = {"--org", "-O", "--base-url"}
82
+
83
+
84
+ def hoist_globals(argv: list) -> list:
85
+ """Let global options appear anywhere (`ipx usage get --json`) by moving them in front of the command.
86
+
87
+ Everything after a literal `--` is left alone, and only unambiguous flags are hoisted (not `-q`, which
88
+ `ipx api` uses for query parameters).
89
+ """
90
+ front: list = []
91
+ rest: list = []
92
+ i = 0
93
+ while i < len(argv):
94
+ a = argv[i]
95
+ if a == "--":
96
+ rest.extend(argv[i:])
97
+ break
98
+ if a in _HOIST_FLAGS:
99
+ front.append(a)
100
+ elif a in _HOIST_VALUE and i + 1 < len(argv):
101
+ front.extend([a, argv[i + 1]])
102
+ i += 1
103
+ elif a.split("=", 1)[0] in _HOIST_VALUE and "=" in a:
104
+ front.append(a)
105
+ else:
106
+ rest.append(a)
107
+ i += 1
108
+ return front + rest
109
+
110
+
111
+ def main(argv: Optional[list] = None) -> None:
112
+ _prepare_streams()
113
+ _register()
114
+ try:
115
+ code = root.main(args=hoist_globals(list(sys.argv[1:] if argv is None else argv)), standalone_mode=False, prog_name="ipx")
116
+ # With standalone_mode=False click *returns* the code of a click.exceptions.Exit instead of raising it.
117
+ if isinstance(code, int) and code:
118
+ sys.exit(code)
119
+ except click.exceptions.Exit as e:
120
+ sys.exit(e.exit_code)
121
+ except click.Abort:
122
+ ui.err.print("\n[muted]Cancelled.[/muted]")
123
+ sys.exit(130)
124
+ except click.UsageError as e:
125
+ hint = f"Try `{e.ctx.command_path} --help`." if e.ctx else None
126
+ ui.fail(e.format_message(), hint)
127
+ sys.exit(2)
128
+ except click.ClickException as e:
129
+ ui.fail(e.format_message())
130
+ sys.exit(e.exit_code)
131
+ except IpxError as e:
132
+ ui.render_error(e)
133
+ sys.exit(e.code)
134
+ except KeyboardInterrupt:
135
+ ui.err.print("\n[muted]Cancelled.[/muted]")
136
+ sys.exit(130)
137
+ except BrokenPipeError:
138
+ sys.exit(0)
139
+
140
+
141
+ if __name__ == "__main__":
142
+ main()
ipx/clickx.py ADDED
@@ -0,0 +1,160 @@
1
+ """Click subclasses that render beautiful, consistent --help with rich."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ import click
9
+ from rich.console import Group
10
+ from rich.padding import Padding
11
+ from rich.table import Table
12
+ from rich.text import Text
13
+
14
+ from ipx import ui
15
+
16
+
17
+ def _opt_row(p: click.Parameter, ctx: click.Context) -> Optional[tuple]:
18
+ if isinstance(p, click.Option):
19
+ if p.hidden:
20
+ return None
21
+ names = ", ".join(p.opts + p.secondary_opts)
22
+ if p.metavar:
23
+ names += f" {p.metavar}"
24
+ elif not p.is_flag and not p.count:
25
+ tn = p.type.name
26
+ names += " " + ("TEXT" if tn in ("text", "string") else "VALUE" if tn == "choice" else tn.upper())
27
+ help_ = p.help or ""
28
+ extras: List[str] = []
29
+ if p.default not in (None, False, (), "") and not p.is_flag and p.show_default:
30
+ extras.append(f"default: {p.default}")
31
+ if p.required:
32
+ extras.append("required")
33
+ if isinstance(p.type, click.Choice):
34
+ extras.append("|".join(p.type.choices))
35
+ return names, help_, extras
36
+ return None
37
+
38
+
39
+ class _HelpMixin:
40
+ section: Optional[str] = None
41
+ examples: List[str] = []
42
+
43
+ def _render_help(self, ctx: click.Context) -> None:
44
+ cmd: click.Command = self # type: ignore[assignment]
45
+ path = ctx.command_path
46
+ parts: List[Any] = []
47
+ if ctx.parent is None:
48
+ parts.append(ui.banner())
49
+ parts.append(Text(""))
50
+ desc = (cmd.help or "").strip()
51
+ if desc:
52
+ first, _, rest = desc.partition("\n\n")
53
+ parts.append(Text(first.replace("\n", " "), style="white"))
54
+ if rest:
55
+ parts.append(Text(rest.replace("\n", " "), style="muted"))
56
+ parts.append(Text(""))
57
+ usage = Text()
58
+ usage.append("USAGE ", style="bold " + ui.ACCENT)
59
+ usage.append(path, style="bold white")
60
+ if isinstance(cmd, click.Group):
61
+ usage.append(" <command>", style=ui.BRAND)
62
+ for p in cmd.params:
63
+ if isinstance(p, click.Argument):
64
+ nm = p.name.upper() + ("..." if p.nargs == -1 else "")
65
+ usage.append(f" {nm}" if p.required else f" [{nm}]", style=ui.VIOLET)
66
+ if any(isinstance(p, click.Option) and not p.hidden for p in cmd.params):
67
+ usage.append(" [options]", style="muted")
68
+ parts.append(usage)
69
+ parts.append(Text(""))
70
+
71
+ if isinstance(cmd, click.Group):
72
+ groups: Dict[str, List[click.Command]] = {}
73
+ for name in cmd.list_commands(ctx):
74
+ sub = cmd.get_command(ctx, name)
75
+ if sub is None or sub.hidden:
76
+ continue
77
+ groups.setdefault(getattr(sub, "section", None) or "Commands", []).append(sub)
78
+ for title, subs in groups.items():
79
+ table = Table.grid(padding=(0, 3))
80
+ table.add_column(style="cmd", no_wrap=True)
81
+ table.add_column(style="white")
82
+ for sub in subs:
83
+ short = (sub.short_help or (sub.help or "").strip().split("\n")[0]).rstrip(".")
84
+ label = sub.name
85
+ if isinstance(sub, click.Group):
86
+ label += " …"
87
+ table.add_row(label, short)
88
+ parts.append(Text(title.upper(), style="bold " + ui.ACCENT))
89
+ parts.append(Padding(table, (0, 0, 1, 2)))
90
+
91
+ args = [p for p in cmd.params if isinstance(p, click.Argument)]
92
+ if args:
93
+ table = Table.grid(padding=(0, 3))
94
+ table.add_column(style=ui.VIOLET, no_wrap=True)
95
+ table.add_column(style="muted")
96
+ for a in args:
97
+ table.add_row(a.name.upper(), getattr(a, "help", "") or ("required" if a.required else "optional"))
98
+ parts.append(Text("ARGUMENTS", style="bold " + ui.ACCENT))
99
+ parts.append(Padding(table, (0, 0, 1, 2)))
100
+
101
+ rows = [r for r in (_opt_row(p, ctx) for p in cmd.get_params(ctx)) if r]
102
+ if rows:
103
+ table = Table.grid(padding=(0, 3))
104
+ table.add_column(style="key", no_wrap=True)
105
+ table.add_column(style="white")
106
+ table.add_column(style="muted")
107
+ for names, help_, extras in rows:
108
+ table.add_row(names, help_, f"[{', '.join(extras)}]" if extras else "")
109
+ parts.append(Text("OPTIONS", style="bold " + ui.ACCENT))
110
+ parts.append(Padding(table, (0, 0, 1, 2)))
111
+
112
+ if self.examples:
113
+ ex = Text()
114
+ for line in self.examples:
115
+ ex.append("$ ", style="muted")
116
+ ex.append(line + "\n", style="white")
117
+ parts.append(Text("EXAMPLES", style="bold " + ui.ACCENT))
118
+ parts.append(Padding(ex, (0, 0, 1, 2)))
119
+
120
+ if isinstance(cmd, click.Group):
121
+ parts.append(Text(f"Run `{path} <command> --help` for details on a command.", style="muted"))
122
+ ui.console.print(Group(*parts))
123
+
124
+ def get_help(self, ctx: click.Context) -> str: # type: ignore[override]
125
+ self._render_help(ctx)
126
+ ctx.exit(0)
127
+ return ""
128
+
129
+
130
+ class IpxCommand(_HelpMixin, click.Command):
131
+ def __init__(self, *a: Any, section: Optional[str] = None, examples: Optional[List[str]] = None, **kw: Any):
132
+ super().__init__(*a, **kw)
133
+ self.section = section
134
+ self.examples = examples or []
135
+
136
+
137
+ class IpxGroup(_HelpMixin, click.Group):
138
+ command_class = IpxCommand
139
+
140
+ def __init__(self, *a: Any, section: Optional[str] = None, examples: Optional[List[str]] = None, **kw: Any):
141
+ kw.setdefault("no_args_is_help", True)
142
+ super().__init__(*a, **kw)
143
+ self.section = section
144
+ self.examples = examples or []
145
+
146
+ def list_commands(self, ctx: click.Context) -> List[str]:
147
+ return list(self.commands) # definition order, not alphabetical
148
+
149
+ def group(self, *a: Any, **kw: Any): # type: ignore[override]
150
+ kw.setdefault("cls", IpxGroup)
151
+ return super().group(*a, **kw)
152
+
153
+ def alias(self, name: str, target: str) -> None:
154
+ """Register a hidden alias of an existing subcommand."""
155
+ cmd = copy.copy(self.commands[target])
156
+ cmd.hidden = True
157
+ self.commands[name] = cmd
158
+
159
+
160
+ IpxGroup.group_class = IpxGroup # subgroups created with @group.group() are IpxGroup too
ipx/cloud.py ADDED
@@ -0,0 +1,340 @@
1
+ """Cloud generation primitives shared by the P2G (/v1, credit-billed) and Lightning (/gen, subscription) tiers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import mimetypes
7
+ import os
8
+ import time
9
+ from dataclasses import dataclass
10
+ from datetime import datetime
11
+ from typing import Any, Dict, Iterator, List, Optional, Tuple
12
+
13
+ from ipx import ui
14
+ from ipx.app import App
15
+ from ipx.chat import Event, parse_openai_sse
16
+ from ipx.errors import IpxError
17
+ from ipx.http import FileSpec, Response, guess_type, poll
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Tier:
22
+ key: str # "p2g" | "gen"
23
+ prefix: str # URL prefix on the API
24
+ title: str
25
+ billing: str # human description of what pays for requests
26
+ default_model_setting: str
27
+
28
+
29
+ P2G = Tier("p2g", "/v1", "Pay-2-Go", "credits from your wallet", "default_p2g_model")
30
+ GEN = Tier("gen", "/gen", "Lightning", "your subscription quotas", "default_gen_model")
31
+
32
+ MODEL_KINDS = {"text": "text", "image": "images", "video": "videos", "audio": "audio", "3d": "3d"}
33
+ MIME_EXT = {
34
+ "image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp", "image/gif": ".gif",
35
+ "video/mp4": ".mp4", "video/webm": ".webm",
36
+ "audio/mpeg": ".mp3", "audio/mp3": ".mp3", "audio/wav": ".wav", "audio/x-wav": ".wav", "audio/ogg": ".ogg",
37
+ "audio/flac": ".flac", "audio/opus": ".opus", "audio/aac": ".aac", "audio/mp4": ".m4a",
38
+ "model/gltf-binary": ".glb", "application/zip": ".zip", "application/json": ".json",
39
+ }
40
+
41
+
42
+ # --------------------------------------------------------------------------- files
43
+
44
+
45
+ def sniff_ext(data: bytes, fallback: str = ".bin") -> str:
46
+ if data[:3] == b"\xff\xd8\xff":
47
+ return ".jpg"
48
+ if data[:8] == b"\x89PNG\r\n\x1a\n":
49
+ return ".png"
50
+ if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
51
+ return ".webp"
52
+ if data[:4] == b"RIFF" and data[8:12] == b"WAVE":
53
+ return ".wav"
54
+ if data[4:8] == b"ftyp":
55
+ return ".mp4"
56
+ if data[:3] == b"ID3" or data[:2] in (b"\xff\xfb", b"\xff\xf3", b"\xff\xf2"):
57
+ return ".mp3"
58
+ if data[:4] == b"OggS":
59
+ return ".ogg"
60
+ if data[:4] == b"fLaC":
61
+ return ".flac"
62
+ if data[:4] == b"glTF":
63
+ return ".glb"
64
+ if data[:2] == b"PK":
65
+ return ".zip"
66
+ return fallback
67
+
68
+
69
+ def out_path(app: App, kind: str, explicit: Optional[str], ext: str, index: int = 0, total: int = 1) -> str:
70
+ """Decide where to write generated media."""
71
+ if explicit:
72
+ explicit = os.path.expanduser(explicit)
73
+ if os.path.isdir(explicit) or explicit.endswith(("/", "\\")):
74
+ os.makedirs(explicit, exist_ok=True)
75
+ base = os.path.join(explicit, f"{kind}-{datetime.now():%Y%m%d-%H%M%S}")
76
+ else:
77
+ root, e = os.path.splitext(explicit)
78
+ base = root
79
+ ext = e or ext
80
+ os.makedirs(os.path.dirname(os.path.abspath(explicit)), exist_ok=True)
81
+ else:
82
+ folder = os.path.expanduser(app.cfg.get("output_dir") or ".")
83
+ os.makedirs(folder, exist_ok=True)
84
+ base = os.path.join(folder, f"{kind}-{datetime.now():%Y%m%d-%H%M%S}")
85
+ if total > 1:
86
+ base += f"-{index + 1}"
87
+ path = base + ext
88
+ n = 1
89
+ while os.path.exists(path) and not explicit:
90
+ path = f"{base}-{n}{ext}"
91
+ n += 1
92
+ return path
93
+
94
+
95
+ def save_bytes(app: App, kind: str, data: bytes, explicit: Optional[str], ext: Optional[str] = None,
96
+ index: int = 0, total: int = 1) -> str:
97
+ path = out_path(app, kind, explicit, ext or sniff_ext(data), index, total)
98
+ with open(path, "wb") as fh:
99
+ fh.write(data)
100
+ return path
101
+
102
+
103
+ def ref_to_url(ref: str) -> str:
104
+ """Turn a local path into a data URI; pass URLs and data URIs through."""
105
+ if ref.startswith(("http://", "https://", "data:")):
106
+ return ref
107
+ path = os.path.expanduser(ref)
108
+ if not os.path.isfile(path):
109
+ raise IpxError(f"Reference image not found: {ref}", "Pass an existing file path or an https:// URL.")
110
+ mime = mimetypes.guess_type(path)[0] or "image/png"
111
+ with open(path, "rb") as fh:
112
+ return f"data:{mime};base64,{base64.b64encode(fh.read()).decode()}"
113
+
114
+
115
+ def read_file_spec(path: str, field: str = "file") -> Dict[str, FileSpec]:
116
+ path = os.path.expanduser(path)
117
+ if not os.path.isfile(path):
118
+ raise IpxError(f"File not found: {path}")
119
+ with open(path, "rb") as fh:
120
+ return {field: (os.path.basename(path), fh.read(), guess_type(path))}
121
+
122
+
123
+ def credits_from(resp: Optional[Response], body: Any = None) -> Optional[float]:
124
+ if resp is not None:
125
+ h = resp.headers.get("x-payg-credits-charged")
126
+ if h:
127
+ try:
128
+ return float(h)
129
+ except ValueError:
130
+ pass
131
+ if isinstance(body, dict):
132
+ u = body.get("usage") or {}
133
+ if isinstance(u, dict) and u.get("payg_credits_charged") is not None:
134
+ return float(u["payg_credits_charged"])
135
+ return None
136
+
137
+
138
+ def report(path_or_paths: Any, *, kind: str, credits: Optional[float], model: Optional[str] = None) -> None:
139
+ paths = path_or_paths if isinstance(path_or_paths, list) else [path_or_paths]
140
+ for p in paths:
141
+ size = ui.fmt_bytes(os.path.getsize(p))
142
+ ui.ok(f"Saved {kind} → [bold]{p}[/bold] [muted]({size})[/muted]")
143
+ bits = []
144
+ if model:
145
+ bits.append(model)
146
+ if credits is not None:
147
+ bits.append(f"{ui.fmt_credits(credits)} credits charged")
148
+ if bits:
149
+ ui.dim(" " + " · ".join(bits))
150
+
151
+
152
+ # --------------------------------------------------------------------------- chat
153
+
154
+
155
+ def chat_backend(app: App, tier: Tier, default_model: Optional[str] = None, **extra: Any):
156
+ def backend(messages: List[Dict[str, Any]], model: Optional[str]) -> Iterator[Event]:
157
+ body = {"messages": messages, "stream": True, "model": model or default_model or "lightning"}
158
+ body.update(extra)
159
+ s = app.api.request("POST", f"{tier.prefix}/chat/completions", json=body, stream=True, timeout=600)
160
+ try:
161
+ yield from parse_openai_sse(s)
162
+ finally:
163
+ s.close()
164
+
165
+ return backend
166
+
167
+
168
+ def list_models(app: App, tier: Tier, kind: Optional[str] = None) -> List[Dict[str, Any]]:
169
+ path = f"{tier.prefix}/models" if not kind else f"{tier.prefix}/{MODEL_KINDS[kind]}/models"
170
+ data = app.api.get(path) or {}
171
+ items = data.get("data") if isinstance(data, dict) else data
172
+ return [m for m in (items or []) if isinstance(m, dict)]
173
+
174
+
175
+ def model_ids(app: App, tier: Tier, kind: str = "text") -> List[str]:
176
+ return [str(m.get("id")) for m in list_models(app, tier, kind) if m.get("id")]
177
+
178
+
179
+ # --------------------------------------------------------------------------- image
180
+
181
+
182
+ def generate_images(app: App, tier: Tier, prompt: str, *, n: int, mode: Optional[str], refs: List[str],
183
+ model: Optional[str]) -> Tuple[List[bytes], Optional[float], Dict[str, Any]]:
184
+ body: Dict[str, Any] = {"prompt": prompt, "n": n, "response_format": "b64_json"}
185
+ if mode:
186
+ body["mode"] = mode
187
+ if model:
188
+ body["model"] = model
189
+ if refs:
190
+ body["image_urls"] = [ref_to_url(r) for r in refs]
191
+ resp = app.api.request("POST", f"{tier.prefix}/images/generations", json=body, timeout=300)
192
+ payload = resp.json() or {}
193
+ images: List[bytes] = []
194
+ for item in payload.get("data") or []:
195
+ if item.get("b64_json"):
196
+ images.append(base64.b64decode(item["b64_json"]))
197
+ elif item.get("url"):
198
+ r = app.api.request("GET", item["url"], headers={"Accept": "*/*"}, auth=False)
199
+ images.append(r.body)
200
+ if not images:
201
+ raise IpxError("The service returned no images.", str(payload)[:300])
202
+ return images, credits_from(resp, payload), payload
203
+
204
+
205
+ # --------------------------------------------------------------------------- video (async)
206
+
207
+
208
+ def _interval(default: float) -> float:
209
+ """Polling interval; IPX_POLL_INTERVAL overrides it (used by tests)."""
210
+ try:
211
+ return float(os.environ.get("IPX_POLL_INTERVAL") or default)
212
+ except ValueError:
213
+ return default
214
+
215
+
216
+ def _progress_fraction(v: Any) -> float:
217
+ try:
218
+ f = float(v)
219
+ except (TypeError, ValueError):
220
+ return 0.0
221
+ return f / 100.0 if f > 1.0 else f
222
+
223
+
224
+ def create_video(app: App, tier: Tier, prompt: str, *, model: Optional[str], size: Optional[str],
225
+ seconds: Optional[int], ref: Optional[str]) -> Dict[str, Any]:
226
+ body: Dict[str, Any] = {"prompt": prompt}
227
+ if model:
228
+ body["model"] = model
229
+ if size:
230
+ body["size"] = size
231
+ if seconds:
232
+ body["seconds"] = seconds
233
+ if ref:
234
+ body["input_reference"] = {"image_url": ref_to_url(ref)}
235
+ return app.api.post(f"{tier.prefix}/videos", body, timeout=120) or {}
236
+
237
+
238
+ def get_video(app: App, tier: Tier, video_id: str) -> Dict[str, Any]:
239
+ return app.api.get(f"{tier.prefix}/videos/{video_id}") or {}
240
+
241
+
242
+ def wait_video(app: App, tier: Tier, job: Dict[str, Any], *, interval: float = 5.0, timeout: float = 1800.0) -> Dict[str, Any]:
243
+ vid = job["id"]
244
+ interval = _interval(interval)
245
+ with ui.job_progress() as prog:
246
+ task = prog.add_task(f"Rendering video {vid}", total=100)
247
+
248
+ def tick(j: Dict[str, Any]) -> None:
249
+ label = {"pending": "Queued", "processing": "Rendering"}.get(str(j.get("status")), str(j.get("status")))
250
+ prog.update(task, completed=_progress_fraction(j.get("progress")) * 100, description=f"{label} · {vid}")
251
+
252
+ final = poll(lambda: get_video(app, tier, vid), done=lambda j: j.get("status") in ("completed", "failed"),
253
+ interval=interval, timeout=timeout, on_tick=tick)
254
+ if final.get("status") == "completed":
255
+ prog.update(task, completed=100, description=f"Rendered · {vid}")
256
+ if final.get("status") == "failed":
257
+ err = final.get("error")
258
+ msg = err.get("message") if isinstance(err, dict) else err
259
+ raise IpxError(f"Video generation failed: {msg or 'unknown error'}", "Credits are refunded automatically on failure.")
260
+ return final
261
+
262
+
263
+ def download_video(app: App, tier: Tier, video_id: str, dest: str) -> int:
264
+ return app.api.download(f"{tier.prefix}/videos/{video_id}/content", dest, label="Downloading video")
265
+
266
+
267
+ # --------------------------------------------------------------------------- audio
268
+
269
+
270
+ def save_audio_response(app: App, kind: str, resp: Response, explicit: Optional[str]) -> List[str]:
271
+ """Persist whatever an audio endpoint returned: raw bytes, or JSON carrying base64 audio."""
272
+ ctype = resp.content_type
273
+ if ctype == "application/json" or resp.body[:1] in (b"{", b"["):
274
+ body = resp.json()
275
+ b64 = None
276
+ if isinstance(body, dict):
277
+ for key in ("audio_base64", "audio_b64", "b64_json", "audio"):
278
+ if isinstance(body.get(key), str):
279
+ b64 = body[key]
280
+ break
281
+ if b64 is None and isinstance(body.get("data"), list) and body["data"]:
282
+ first = body["data"][0]
283
+ b64 = first.get("b64_json") or first.get("audio_base64") if isinstance(first, dict) else None
284
+ if b64:
285
+ paths = [save_bytes(app, kind, base64.b64decode(b64), explicit)]
286
+ meta = {k: v for k, v in body.items() if not isinstance(v, str) or len(v) < 2000}
287
+ if len(meta) > 1:
288
+ import json
289
+
290
+ side = os.path.splitext(paths[0])[0] + ".json"
291
+ with open(side, "w", encoding="utf-8") as fh:
292
+ json.dump(meta, fh, indent=2)
293
+ paths.append(side)
294
+ return paths
295
+ raise IpxError("The service returned JSON but no audio.", resp.text[:300])
296
+ ext = MIME_EXT.get(ctype) or sniff_ext(resp.body, ".mp3")
297
+ return [save_bytes(app, kind, resp.body, explicit, ext)]
298
+
299
+
300
+ # --------------------------------------------------------------------------- 3D (P2G only)
301
+
302
+ OUT3D = {"model_glb_b64_bytes": ".glb", "model_ply_b64_bytes": ".ply", "orbit_video_b64_bytes": ".mp4"}
303
+
304
+
305
+ def generate_3d(app: App, *, model: str, image: str, prompt: Optional[str], resolution: Optional[str],
306
+ explicit: Optional[str], wait: bool = True) -> Tuple[Dict[str, Any], List[str]]:
307
+ body: Dict[str, Any] = {"model": model, "image_urls": [ref_to_url(image)]}
308
+ if prompt:
309
+ body["prompt"] = prompt
310
+ if resolution:
311
+ body["resolution"] = resolution
312
+ job = app.api.post("/v1/3d/generations", body, timeout=120) or {}
313
+ if not wait:
314
+ return job, []
315
+ job_id = job.get("job_id")
316
+ if not job_id:
317
+ raise IpxError("The service did not return a job id.", str(job)[:300])
318
+ with ui.job_progress() as prog:
319
+ task = prog.add_task(f"3D job {job_id[:10]}…", total=None)
320
+
321
+ def fetch() -> Dict[str, Any]:
322
+ return app.api.request("GET", f"/v1/3d/jobs/{job_id}").json() or {}
323
+
324
+ final = poll(fetch, done=lambda j: j.get("status") in ("completed", "failed"), interval=_interval(6.0), timeout=1800.0,
325
+ on_tick=lambda j: prog.update(task, description=f"{str(j.get('status', 'pending')).title()} · {job_id[:10]}…"))
326
+ prog.update(task, total=1, completed=1)
327
+ if final.get("status") == "failed":
328
+ raise IpxError(f"3D generation failed: {final.get('error')}", "Credits are refunded automatically on failure.")
329
+ saved: List[str] = []
330
+ stem = os.path.splitext(os.path.expanduser(explicit))[0] if explicit else None
331
+ for item in final.get("data") or []:
332
+ for key, ext in OUT3D.items():
333
+ if item.get(key):
334
+ target = (stem + ext) if stem else None
335
+ saved.append(save_bytes(app, "3d", base64.b64decode(item[key]), target, ext))
336
+ return final, saved
337
+
338
+
339
+ def now() -> float:
340
+ return time.time()
File without changes