venice-cli 0.14.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.
- venice/__init__.py +1 -0
- venice/__main__.py +6 -0
- venice/audio_player.py +59 -0
- venice/audio_post.py +274 -0
- venice/auth.py +101 -0
- venice/billing.py +129 -0
- venice/cli.py +31 -0
- venice/client.py +258 -0
- venice/commands/__init__.py +22 -0
- venice/commands/_audio.py +96 -0
- venice/commands/_models.py +83 -0
- venice/commands/_openai.py +62 -0
- venice/commands/_queue.py +87 -0
- venice/commands/_shared.py +132 -0
- venice/commands/balance.py +124 -0
- venice/commands/bg_remove.py +126 -0
- venice/commands/chat.py +263 -0
- venice/commands/contact_sheet.py +59 -0
- venice/commands/embed.py +157 -0
- venice/commands/image.py +657 -0
- venice/commands/login.py +29 -0
- venice/commands/master.py +42 -0
- venice/commands/models.py +174 -0
- venice/commands/music.py +316 -0
- venice/commands/sfx.py +199 -0
- venice/commands/tts.py +362 -0
- venice/commands/upscale.py +177 -0
- venice/commands/video.py +298 -0
- venice/config.py +19 -0
- venice/image_montage.py +230 -0
- venice_cli-0.14.0.dist-info/METADATA +654 -0
- venice_cli-0.14.0.dist-info/RECORD +35 -0
- venice_cli-0.14.0.dist-info/WHEEL +4 -0
- venice_cli-0.14.0.dist-info/entry_points.txt +2 -0
- venice_cli-0.14.0.dist-info/licenses/LICENSE +21 -0
venice/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.14.0"
|
venice/__main__.py
ADDED
venice/audio_player.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Detect and invoke a local audio player. Synchronous; non-fatal on failure."""
|
|
2
|
+
import shutil
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Callable, List, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
_PLAYERS: List[Tuple[str, Callable[[Path], List[str]]]] = [
|
|
9
|
+
("paplay", lambda p: ["paplay", str(p)]),
|
|
10
|
+
("aplay", lambda p: ["aplay", "-q", str(p)]),
|
|
11
|
+
("ffplay", lambda p: ["ffplay", "-nodisp", "-autoexit", "-loglevel", "quiet", str(p)]),
|
|
12
|
+
("mpg123", lambda p: ["mpg123", "-q", str(p)]),
|
|
13
|
+
("play", lambda p: ["play", "-q", str(p)]),
|
|
14
|
+
("afplay", lambda p: ["afplay", str(p)]),
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def find_player() -> Optional[Tuple[str, Callable[[Path], List[str]]]]:
|
|
19
|
+
for name, builder in _PLAYERS:
|
|
20
|
+
if shutil.which(name):
|
|
21
|
+
return name, builder
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def has_player() -> bool:
|
|
26
|
+
return find_player() is not None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def play(path: Path) -> bool:
|
|
30
|
+
"""Play synchronously. Returns True on exit-0, False otherwise.
|
|
31
|
+
|
|
32
|
+
Never raises. Prints a hint to stderr if no player exists.
|
|
33
|
+
"""
|
|
34
|
+
found = find_player()
|
|
35
|
+
if not found:
|
|
36
|
+
print(
|
|
37
|
+
f"no audio player found (tried paplay/aplay/ffplay/mpg123/play/afplay)\n"
|
|
38
|
+
f"saved to {path}; play manually with your preferred tool.",
|
|
39
|
+
file=sys.stderr,
|
|
40
|
+
)
|
|
41
|
+
return False
|
|
42
|
+
name, build = found
|
|
43
|
+
try:
|
|
44
|
+
completed = subprocess.run(
|
|
45
|
+
build(path),
|
|
46
|
+
stdin=subprocess.DEVNULL,
|
|
47
|
+
stdout=subprocess.DEVNULL,
|
|
48
|
+
stderr=subprocess.DEVNULL,
|
|
49
|
+
)
|
|
50
|
+
if completed.returncode != 0:
|
|
51
|
+
print(
|
|
52
|
+
f"{name} exited {completed.returncode}; saved to {path}",
|
|
53
|
+
file=sys.stderr,
|
|
54
|
+
)
|
|
55
|
+
return False
|
|
56
|
+
return True
|
|
57
|
+
except (OSError, KeyboardInterrupt) as e:
|
|
58
|
+
print(f"playback aborted ({e}); file is at {path}", file=sys.stderr)
|
|
59
|
+
return False
|
venice/audio_post.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Local audio mastering via `ffmpeg`/`ffprobe`. Shells out; never touches the API.
|
|
2
|
+
|
|
3
|
+
Produces a 48k/24-bit WAV master with 2-pass `loudnorm` (LUFS + true-peak) and
|
|
4
|
+
an optional seamless loop (crossfade tail->head). ffmpeg/ffprobe are external
|
|
5
|
+
dependencies, detected at call time; missing tools fail cleanly before any work.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import List, Optional
|
|
15
|
+
|
|
16
|
+
_CODECS = {16: "pcm_s16le", 24: "pcm_s24le", 32: "pcm_s32le"}
|
|
17
|
+
|
|
18
|
+
# Stand-in measured values for --dry-run display (real ones come from pass 1).
|
|
19
|
+
_PLACEHOLDER_MEASURED = {
|
|
20
|
+
"input_i": "<I>", "input_tp": "<TP>", "input_lra": "<LRA>",
|
|
21
|
+
"input_thresh": "<thresh>", "target_offset": "<offset>",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def has_ffmpeg() -> bool:
|
|
26
|
+
return shutil.which("ffmpeg") is not None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def has_ffprobe() -> bool:
|
|
30
|
+
return shutil.which("ffprobe") is not None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def default_output(input_path: Path) -> Path:
|
|
34
|
+
"""`foo.mp3` -> `foo.mastered.wav` next to the input."""
|
|
35
|
+
return input_path.with_name(input_path.stem + ".mastered.wav")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def add_master_flags(parser, *, include_toggle: bool) -> None:
|
|
39
|
+
"""Shared mastering flags for `master`, `music`, and `sfx`.
|
|
40
|
+
|
|
41
|
+
`include_toggle` adds the `--master` on/off switch (music/sfx only; the
|
|
42
|
+
standalone `master` command always masters)."""
|
|
43
|
+
if include_toggle:
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"--master",
|
|
46
|
+
action="store_true",
|
|
47
|
+
help="After saving, master to WAV (48k/24-bit, LUFS/true-peak; needs ffmpeg).",
|
|
48
|
+
)
|
|
49
|
+
parser.add_argument("--lufs", type=float, default=-16.0, metavar="LUFS",
|
|
50
|
+
help="Integrated loudness target (default -16).")
|
|
51
|
+
parser.add_argument("--true-peak", type=float, default=-1.0, dest="true_peak",
|
|
52
|
+
metavar="DBTP", help="True-peak ceiling in dBTP (default -1.0).")
|
|
53
|
+
parser.add_argument("--sample-rate", type=int, default=48000, dest="sample_rate",
|
|
54
|
+
metavar="HZ", help="Output sample rate (default 48000).")
|
|
55
|
+
parser.add_argument("--bit-depth", type=int, choices=(16, 24, 32), default=24,
|
|
56
|
+
dest="bit_depth", help="Output PCM bit depth (default 24).")
|
|
57
|
+
parser.add_argument("--loop", action="store_true",
|
|
58
|
+
help="Make it seamlessly loopable (crossfade tail into head).")
|
|
59
|
+
parser.add_argument("--loop-crossfade", type=float, default=2.0, dest="loop_crossfade",
|
|
60
|
+
metavar="SEC", help="Loop crossfade length in seconds (default 2).")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def master_kwargs(args) -> dict:
|
|
64
|
+
"""Pull the mastering knobs off a parsed namespace into master() kwargs."""
|
|
65
|
+
return dict(
|
|
66
|
+
sample_rate=args.sample_rate,
|
|
67
|
+
bit_depth=args.bit_depth,
|
|
68
|
+
lufs=args.lufs,
|
|
69
|
+
true_peak=args.true_peak,
|
|
70
|
+
loop=args.loop,
|
|
71
|
+
loop_crossfade=args.loop_crossfade,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def master_hook(args):
|
|
76
|
+
"""Post-save callback (path -> exit code) for the music/sfx `--master` flag:
|
|
77
|
+
masters the just-written file to `<file>.mastered.wav` in place."""
|
|
78
|
+
def _run(path: Path) -> int:
|
|
79
|
+
return master(path, default_output(path), **master_kwargs(args))
|
|
80
|
+
return _run
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _n(v: float) -> str:
|
|
84
|
+
return f"{v:g}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _loudnorm(lufs: float, tp: float, measured: Optional[dict]) -> str:
|
|
88
|
+
"""Build a loudnorm filter string. Pass 1 (measured=None) prints JSON stats;
|
|
89
|
+
pass 2 feeds the measured values back for an accurate linear normalization."""
|
|
90
|
+
base = f"loudnorm=I={_n(lufs)}:TP={_n(tp)}:LRA=11"
|
|
91
|
+
if measured is None:
|
|
92
|
+
return base + ":print_format=json"
|
|
93
|
+
return (
|
|
94
|
+
base
|
|
95
|
+
+ f":measured_I={measured['input_i']}"
|
|
96
|
+
+ f":measured_TP={measured['input_tp']}"
|
|
97
|
+
+ f":measured_LRA={measured['input_lra']}"
|
|
98
|
+
+ f":measured_thresh={measured['input_thresh']}"
|
|
99
|
+
+ f":offset={measured['target_offset']}"
|
|
100
|
+
+ ":linear=true:print_format=summary"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _loop_filter(src: str, dur: Optional[float], cf: float) -> str:
|
|
105
|
+
"""Filtergraph (from label `src` to `[out]`) that folds the last `cf`s of the
|
|
106
|
+
stream, faded out, over the first `cf`s, faded in -- a click-free loop of
|
|
107
|
+
length `dur - cf`. `dur=None` emits placeholders for dry-run display."""
|
|
108
|
+
c = _n(cf)
|
|
109
|
+
if dur is None:
|
|
110
|
+
d, tail, body = "<DUR>", "<DUR-CF>", "<DUR-CF>"
|
|
111
|
+
else:
|
|
112
|
+
d, tail, body = _n(dur), _n(dur - cf), _n(dur - cf)
|
|
113
|
+
return (
|
|
114
|
+
f"[{src}]asplit=3[la][lb][lc];"
|
|
115
|
+
f"[la]atrim=0:{c},asetpts=N/SR/TB,afade=t=in:st=0:d={c}[head];"
|
|
116
|
+
f"[lb]atrim={tail}:{d},asetpts=N/SR/TB,afade=t=out:st=0:d={c}[tail];"
|
|
117
|
+
f"[head][tail]amix=inputs=2:normalize=0[seam];"
|
|
118
|
+
f"[lc]atrim={c}:{body},asetpts=N/SR/TB[mid];"
|
|
119
|
+
f"[seam][mid]concat=n=2:v=0:a=1[out]"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _pass1_cmd(input_path: Path, lufs: float, tp: float) -> List[str]:
|
|
124
|
+
return [
|
|
125
|
+
"ffmpeg", "-hide_banner", "-nostats", "-i", str(input_path),
|
|
126
|
+
"-af", _loudnorm(lufs, tp, None), "-f", "null", "-",
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _pass2_cmd(input_path: Path, output_path: Path, *, lufs: float, tp: float,
|
|
131
|
+
measured: Optional[dict], codec: str, sample_rate: int,
|
|
132
|
+
loop: bool, dur: Optional[float], cf: float) -> List[str]:
|
|
133
|
+
cmd = ["ffmpeg", "-hide_banner", "-nostats", "-y", "-i", str(input_path)]
|
|
134
|
+
norm = _loudnorm(lufs, tp, measured)
|
|
135
|
+
if loop:
|
|
136
|
+
graph = f"[0:a]{norm}[m];" + _loop_filter("m", dur, cf)
|
|
137
|
+
cmd += ["-filter_complex", graph, "-map", "[out]"]
|
|
138
|
+
else:
|
|
139
|
+
cmd += ["-af", norm]
|
|
140
|
+
cmd += ["-ar", str(sample_rate), "-c:a", codec, str(output_path)]
|
|
141
|
+
return cmd
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _parse_loudnorm_json(stderr: str) -> dict:
|
|
145
|
+
"""Extract the trailing JSON block ffmpeg's loudnorm prints on stderr."""
|
|
146
|
+
start = stderr.rfind("{")
|
|
147
|
+
end = stderr.rfind("}")
|
|
148
|
+
if start == -1 or end == -1 or end < start:
|
|
149
|
+
raise ValueError("no loudnorm JSON found in ffmpeg output")
|
|
150
|
+
return json.loads(stderr[start:end + 1])
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _run(cmd: List[str]):
|
|
154
|
+
try:
|
|
155
|
+
return subprocess.run(
|
|
156
|
+
cmd,
|
|
157
|
+
stdin=subprocess.DEVNULL,
|
|
158
|
+
stdout=subprocess.DEVNULL,
|
|
159
|
+
stderr=subprocess.PIPE,
|
|
160
|
+
text=True,
|
|
161
|
+
)
|
|
162
|
+
except OSError as e:
|
|
163
|
+
print(f"failed to run {cmd[0]}: {e}", file=sys.stderr)
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _probe_duration(input_path: Path) -> Optional[float]:
|
|
168
|
+
cp = _run_capture([
|
|
169
|
+
"ffprobe", "-v", "error", "-show_entries", "format=duration",
|
|
170
|
+
"-of", "default=noprint_wrappers=1:nokey=1", str(input_path),
|
|
171
|
+
])
|
|
172
|
+
if cp is None or cp.returncode != 0:
|
|
173
|
+
return None
|
|
174
|
+
try:
|
|
175
|
+
return float((cp.stdout or "").strip())
|
|
176
|
+
except (TypeError, ValueError):
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _run_capture(cmd: List[str]):
|
|
181
|
+
try:
|
|
182
|
+
return subprocess.run(
|
|
183
|
+
cmd,
|
|
184
|
+
stdin=subprocess.DEVNULL,
|
|
185
|
+
stdout=subprocess.PIPE,
|
|
186
|
+
stderr=subprocess.PIPE,
|
|
187
|
+
text=True,
|
|
188
|
+
)
|
|
189
|
+
except OSError as e:
|
|
190
|
+
print(f"failed to run {cmd[0]}: {e}", file=sys.stderr)
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def master(
|
|
195
|
+
input_path: Path,
|
|
196
|
+
output_path: Path,
|
|
197
|
+
*,
|
|
198
|
+
sample_rate: int = 48000,
|
|
199
|
+
bit_depth: int = 24,
|
|
200
|
+
lufs: float = -16.0,
|
|
201
|
+
true_peak: float = -1.0,
|
|
202
|
+
loop: bool = False,
|
|
203
|
+
loop_crossfade: float = 2.0,
|
|
204
|
+
dry_run: bool = False,
|
|
205
|
+
) -> int:
|
|
206
|
+
"""Master `input_path` to a WAV at `output_path`. Returns an exit code
|
|
207
|
+
(0 ok, 2 bad arg / missing ffmpeg, 5 ffmpeg failure)."""
|
|
208
|
+
codec = _CODECS.get(bit_depth)
|
|
209
|
+
if codec is None:
|
|
210
|
+
print(f"master: unsupported --bit-depth {bit_depth}", file=sys.stderr)
|
|
211
|
+
return 2
|
|
212
|
+
|
|
213
|
+
if not dry_run and not has_ffmpeg():
|
|
214
|
+
print("master: ffmpeg not found on PATH; install it (e.g. apt install ffmpeg)",
|
|
215
|
+
file=sys.stderr)
|
|
216
|
+
return 2
|
|
217
|
+
|
|
218
|
+
dur: Optional[float] = None
|
|
219
|
+
if loop and not dry_run:
|
|
220
|
+
if not has_ffprobe():
|
|
221
|
+
print("master: --loop needs ffprobe (ships with ffmpeg)", file=sys.stderr)
|
|
222
|
+
return 2
|
|
223
|
+
dur = _probe_duration(input_path)
|
|
224
|
+
if dur is None:
|
|
225
|
+
print(f"master: could not read duration of {input_path}", file=sys.stderr)
|
|
226
|
+
return 5
|
|
227
|
+
if dur <= 2 * loop_crossfade:
|
|
228
|
+
print(
|
|
229
|
+
f"master: input is {dur:.2f}s; too short to loop with a "
|
|
230
|
+
f"{loop_crossfade:g}s crossfade (need > {2 * loop_crossfade:g}s)",
|
|
231
|
+
file=sys.stderr,
|
|
232
|
+
)
|
|
233
|
+
return 2
|
|
234
|
+
|
|
235
|
+
cmd1 = _pass1_cmd(input_path, lufs, true_peak)
|
|
236
|
+
|
|
237
|
+
if dry_run:
|
|
238
|
+
cmd2_template = _pass2_cmd(
|
|
239
|
+
input_path, output_path, lufs=lufs, tp=true_peak,
|
|
240
|
+
measured=_PLACEHOLDER_MEASURED, codec=codec, sample_rate=sample_rate,
|
|
241
|
+
loop=loop, dur=dur, cf=loop_crossfade,
|
|
242
|
+
)
|
|
243
|
+
print("master: dry run -- would run:", file=sys.stderr)
|
|
244
|
+
print(" pass 1 (measure): " + " ".join(cmd1), file=sys.stderr)
|
|
245
|
+
print(" pass 2 (apply): " + " ".join(cmd2_template), file=sys.stderr)
|
|
246
|
+
print(" (pass 2's measured_* / <DUR> values are resolved at run time)", file=sys.stderr)
|
|
247
|
+
return 0
|
|
248
|
+
|
|
249
|
+
r1 = _run_capture(cmd1)
|
|
250
|
+
if r1 is None or r1.returncode != 0:
|
|
251
|
+
print("master: loudness analysis (pass 1) failed", file=sys.stderr)
|
|
252
|
+
if r1 is not None and r1.stderr:
|
|
253
|
+
print(r1.stderr.strip()[-500:], file=sys.stderr)
|
|
254
|
+
return 5
|
|
255
|
+
try:
|
|
256
|
+
measured = _parse_loudnorm_json(r1.stderr or "")
|
|
257
|
+
except (ValueError, json.JSONDecodeError):
|
|
258
|
+
print("master: could not parse loudnorm stats from ffmpeg", file=sys.stderr)
|
|
259
|
+
return 5
|
|
260
|
+
|
|
261
|
+
cmd2 = _pass2_cmd(
|
|
262
|
+
input_path, output_path, lufs=lufs, tp=true_peak, measured=measured,
|
|
263
|
+
codec=codec, sample_rate=sample_rate, loop=loop, dur=dur, cf=loop_crossfade,
|
|
264
|
+
)
|
|
265
|
+
r2 = _run(cmd2)
|
|
266
|
+
if r2 is None or r2.returncode != 0:
|
|
267
|
+
print("master: encode (pass 2) failed", file=sys.stderr)
|
|
268
|
+
if r2 is not None and r2.stderr:
|
|
269
|
+
print(r2.stderr.strip()[-500:], file=sys.stderr)
|
|
270
|
+
return 5
|
|
271
|
+
|
|
272
|
+
print(str(output_path.resolve()))
|
|
273
|
+
print(f"mastered -> {output_path.resolve()}", file=sys.stderr)
|
|
274
|
+
return 0
|
venice/auth.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Credential read/write. Never logs the key. Never prints the key."""
|
|
2
|
+
import getpass
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from . import config
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AuthError(Exception):
|
|
11
|
+
"""Credential not found or unusable. Message is safe to print."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_key() -> str:
|
|
15
|
+
"""Resolve API key with precedence: env var > credentials file > AuthError."""
|
|
16
|
+
env_val = os.environ.get(config.ENV_API_KEY, "").strip()
|
|
17
|
+
if env_val:
|
|
18
|
+
return env_val
|
|
19
|
+
|
|
20
|
+
p: Path = config.CREDS_FILE
|
|
21
|
+
if not p.exists():
|
|
22
|
+
raise AuthError(
|
|
23
|
+
f"No API key found. Set ${config.ENV_API_KEY} or run: venice login"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
mode = p.stat().st_mode & 0o777
|
|
28
|
+
if mode & 0o077:
|
|
29
|
+
print(
|
|
30
|
+
f"warning: {p} has loose permissions ({oct(mode)}); "
|
|
31
|
+
f"run `chmod 600 {p}`",
|
|
32
|
+
file=sys.stderr,
|
|
33
|
+
)
|
|
34
|
+
key = p.read_text(encoding="utf-8").strip()
|
|
35
|
+
except OSError as e:
|
|
36
|
+
raise AuthError(f"Cannot read {p}: {e}") from None
|
|
37
|
+
|
|
38
|
+
if not key:
|
|
39
|
+
raise AuthError(f"{p} is empty. Run: venice login")
|
|
40
|
+
return key
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def save_key(key: str) -> Path:
|
|
44
|
+
"""Atomically write the key with mode 0600. Returns the path written."""
|
|
45
|
+
key = (key or "").strip()
|
|
46
|
+
if not key:
|
|
47
|
+
raise AuthError("Refusing to save an empty key.")
|
|
48
|
+
if any(c.isspace() for c in key):
|
|
49
|
+
raise AuthError("Key contains whitespace; check what you pasted.")
|
|
50
|
+
|
|
51
|
+
config.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
try:
|
|
53
|
+
os.chmod(config.CONFIG_DIR, 0o700)
|
|
54
|
+
except OSError:
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
tmp = config.CREDS_FILE.with_suffix(".tmp")
|
|
58
|
+
fd = os.open(
|
|
59
|
+
str(tmp),
|
|
60
|
+
os.O_WRONLY | os.O_CREAT | os.O_TRUNC,
|
|
61
|
+
0o600,
|
|
62
|
+
)
|
|
63
|
+
try:
|
|
64
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
65
|
+
f.write(key + "\n")
|
|
66
|
+
f.flush()
|
|
67
|
+
os.fsync(f.fileno())
|
|
68
|
+
except Exception:
|
|
69
|
+
try:
|
|
70
|
+
os.unlink(tmp)
|
|
71
|
+
except OSError:
|
|
72
|
+
pass
|
|
73
|
+
raise
|
|
74
|
+
os.replace(tmp, config.CREDS_FILE)
|
|
75
|
+
try:
|
|
76
|
+
os.chmod(config.CREDS_FILE, 0o600)
|
|
77
|
+
except OSError:
|
|
78
|
+
pass
|
|
79
|
+
return config.CREDS_FILE
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def prompt_and_save() -> Path:
|
|
83
|
+
"""Interactive prompt used by `venice login`. Uses getpass -- no echo.
|
|
84
|
+
|
|
85
|
+
Refuses non-TTY stdin so we don't fall back to cleartext input.
|
|
86
|
+
"""
|
|
87
|
+
if not sys.stdin.isatty():
|
|
88
|
+
raise AuthError(
|
|
89
|
+
f"Interactive login requires a TTY. "
|
|
90
|
+
f"Set ${config.ENV_API_KEY} in your environment instead."
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
print(
|
|
94
|
+
"Paste your Venice API key (from https://venice.ai/settings/api).",
|
|
95
|
+
file=sys.stderr,
|
|
96
|
+
)
|
|
97
|
+
print("Input is hidden; it will not appear on screen.", file=sys.stderr)
|
|
98
|
+
key = getpass.getpass(prompt="API key: ")
|
|
99
|
+
path = save_key(key)
|
|
100
|
+
print(f"Saved {len(key)}-char key to {path} (mode 0600).", file=sys.stderr)
|
|
101
|
+
return path
|
venice/billing.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Small wrapper over /api_keys/rate_limits for balance + tier info.
|
|
2
|
+
|
|
3
|
+
Kept separate from client.py so commands can import a stable, narrow API
|
|
4
|
+
without depending on the raw HTTP shape.
|
|
5
|
+
|
|
6
|
+
## Venice's billing model
|
|
7
|
+
|
|
8
|
+
Venice has up to four spendable buckets, drained in this order:
|
|
9
|
+
|
|
10
|
+
1. DIEM allowance -- daily credit derived from staked DIEM (1 DIEM
|
|
11
|
+
staked = $1/day). Resets every 24 h at
|
|
12
|
+
`nextEpochBegins`. Per-epoch use-it-or-lose-it.
|
|
13
|
+
2. Monthly credit -- BUNDLED_CREDITS, a recently-added bundle
|
|
14
|
+
granted with paid subscriptions. Drains BEFORE
|
|
15
|
+
cash. NOT exposed via the inference-key
|
|
16
|
+
endpoint -- only via /billing/balance (admin).
|
|
17
|
+
3. VCU -- Venice Compute Units (per-tier inclusions).
|
|
18
|
+
Also not exposed via the inference key.
|
|
19
|
+
4. USD cash -- one-and-done prepaid USD balance.
|
|
20
|
+
|
|
21
|
+
1 unit of any of these == $1 of purchasing power: per-model pricing in
|
|
22
|
+
/models lists the same number in both `usd` and `diem` fields.
|
|
23
|
+
|
|
24
|
+
What we can read with an inference key (`VENICE-INFERENCE-KEY-...`):
|
|
25
|
+
- balances.USD (cash)
|
|
26
|
+
- balances.DIEM (epoch allowance, the remaining portion for the
|
|
27
|
+
current 24 h)
|
|
28
|
+
- nextEpochBegins (when DIEM resets)
|
|
29
|
+
- apiTier, keyExpiration, rateLimits
|
|
30
|
+
|
|
31
|
+
What we cannot read with this key:
|
|
32
|
+
- BUNDLED_CREDITS (monthly bundle balance)
|
|
33
|
+
- VCU (compute-unit balance)
|
|
34
|
+
|
|
35
|
+
Practical implication: when the user has monthly credit, the CLI's
|
|
36
|
+
"after charge" line will be slightly pessimistic about USD cash (since
|
|
37
|
+
the actual debit lands on monthly first). The combined spendable total
|
|
38
|
+
shown still reflects what we can see; if your real spendable is higher,
|
|
39
|
+
it means monthly credit / VCU is doing some of the lifting silently.
|
|
40
|
+
"""
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
from typing import Optional
|
|
44
|
+
|
|
45
|
+
# Hard-coded because the spec doesn't expose it as a field on this endpoint.
|
|
46
|
+
SPEND_ORDER = ("DIEM allowance", "monthly credit", "USD cash")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def fetch_balance(client) -> Optional[dict]:
|
|
50
|
+
"""Return {usd, diem, total, tier, next_epoch, key_expires} or None.
|
|
51
|
+
|
|
52
|
+
`total` is the combined spendable balance in USD-equivalent units
|
|
53
|
+
(USD + DIEM, since 1 DIEM = $1).
|
|
54
|
+
"""
|
|
55
|
+
data = client.get_balance()
|
|
56
|
+
if not isinstance(data, dict):
|
|
57
|
+
return None
|
|
58
|
+
balances = data.get("balances") if isinstance(data.get("balances"), dict) else {}
|
|
59
|
+
tier_block = data.get("apiTier") if isinstance(data.get("apiTier"), dict) else {}
|
|
60
|
+
usd = balances.get("USD")
|
|
61
|
+
diem = balances.get("DIEM")
|
|
62
|
+
total = _safe_sum(usd, diem)
|
|
63
|
+
return {
|
|
64
|
+
"usd": usd,
|
|
65
|
+
"diem": diem,
|
|
66
|
+
"total": total,
|
|
67
|
+
"tier": tier_block.get("id"),
|
|
68
|
+
"is_charged": tier_block.get("isCharged"),
|
|
69
|
+
"next_epoch": data.get("nextEpochBegins"),
|
|
70
|
+
"key_expires": data.get("keyExpiration"),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _safe_sum(*vals) -> Optional[float]:
|
|
75
|
+
"""Sum that returns None if any input is non-numeric (and 0 if all None)."""
|
|
76
|
+
total = 0.0
|
|
77
|
+
any_seen = False
|
|
78
|
+
for v in vals:
|
|
79
|
+
if v is None:
|
|
80
|
+
continue
|
|
81
|
+
try:
|
|
82
|
+
total += float(v)
|
|
83
|
+
any_seen = True
|
|
84
|
+
except (TypeError, ValueError):
|
|
85
|
+
return None
|
|
86
|
+
return total if any_seen else None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def format_usd(amt) -> str:
|
|
90
|
+
"""Pretty-print a USD amount. 4 decimals for sub-dollar, 2 otherwise."""
|
|
91
|
+
if amt is None:
|
|
92
|
+
return "$?.?? USD"
|
|
93
|
+
try:
|
|
94
|
+
v = float(amt)
|
|
95
|
+
except (TypeError, ValueError):
|
|
96
|
+
return f"{amt} USD"
|
|
97
|
+
if abs(v) < 1:
|
|
98
|
+
return f"${v:.4f} USD"
|
|
99
|
+
return f"${v:.2f} USD"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def format_balance_breakdown(info: dict) -> str:
|
|
103
|
+
"""Render a one-line balance summary with the spendable total + breakdown.
|
|
104
|
+
|
|
105
|
+
Example: "$32.70 USD (6.56 DIEM allowance + 26.14 USD cash)"
|
|
106
|
+
Order mirrors the spend order (DIEM drains first), so the leftmost
|
|
107
|
+
bucket is what funds the next call. Falls back gracefully if a
|
|
108
|
+
component is missing.
|
|
109
|
+
"""
|
|
110
|
+
if not isinstance(info, dict):
|
|
111
|
+
return "(no balance info)"
|
|
112
|
+
usd = info.get("usd")
|
|
113
|
+
diem = info.get("diem")
|
|
114
|
+
total = info.get("total")
|
|
115
|
+
head = format_usd(total)
|
|
116
|
+
parts = []
|
|
117
|
+
if diem is not None:
|
|
118
|
+
try:
|
|
119
|
+
parts.append(f"{float(diem):.2f} DIEM allowance")
|
|
120
|
+
except (TypeError, ValueError):
|
|
121
|
+
pass
|
|
122
|
+
if usd is not None:
|
|
123
|
+
try:
|
|
124
|
+
parts.append(f"{float(usd):.2f} USD cash")
|
|
125
|
+
except (TypeError, ValueError):
|
|
126
|
+
pass
|
|
127
|
+
if parts:
|
|
128
|
+
return f"{head} ({' + '.join(parts)})"
|
|
129
|
+
return head
|
venice/cli.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Top-level argparse dispatcher."""
|
|
2
|
+
import argparse
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from . import __version__
|
|
6
|
+
from .commands import register_all
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
10
|
+
p = argparse.ArgumentParser(
|
|
11
|
+
prog="venice",
|
|
12
|
+
description="Venice.ai CLI (stdlib-only). `venice <command> --help` for details.",
|
|
13
|
+
)
|
|
14
|
+
p.add_argument("--version", action="version", version=f"venice {__version__}")
|
|
15
|
+
sub = p.add_subparsers(dest="command", metavar="COMMAND")
|
|
16
|
+
register_all(sub)
|
|
17
|
+
return p
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main(argv=None) -> int:
|
|
21
|
+
parser = build_parser()
|
|
22
|
+
args = parser.parse_args(argv)
|
|
23
|
+
handler = getattr(args, "handler", None)
|
|
24
|
+
if handler is None:
|
|
25
|
+
parser.print_help(sys.stderr)
|
|
26
|
+
return 2
|
|
27
|
+
return int(handler(args) or 0)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
if __name__ == "__main__":
|
|
31
|
+
raise SystemExit(main())
|