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/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()
|
ipx/commands/__init__.py
ADDED
|
File without changes
|