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/client.py ADDED
@@ -0,0 +1,258 @@
1
+ """Thin Venice.ai HTTP client built on urllib. No third-party deps.
2
+
3
+ Returns dicts for JSON responses, bytes for binary (audio/image).
4
+ Maps non-2xx to VeniceAPIError with status, URL, and a body excerpt.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ import time
11
+ import urllib.error
12
+ import urllib.parse
13
+ import urllib.request
14
+ from typing import Any, Callable, Optional, Tuple, Union
15
+
16
+ from . import config
17
+
18
+
19
+ class VeniceAPIError(Exception):
20
+ """HTTP-level error from the Venice API.
21
+
22
+ Attributes:
23
+ status: HTTP status code (0 if connection failed pre-response).
24
+ url: final request URL.
25
+ body: excerpt of the response body (first ~2 KB), for debugging.
26
+ code: Venice API error code (e.g. INSUFFICIENT_BALANCE), if parseable.
27
+ """
28
+
29
+ def __init__(self, status: int, url: str, body: str, code: Optional[str] = None):
30
+ self.status = status
31
+ self.url = url
32
+ self.body = body
33
+ self.code = code
34
+ msg = f"HTTP {status} from {url}"
35
+ if code:
36
+ msg += f" [{code}]"
37
+ if body:
38
+ msg += f"\n body: {body[:500]}"
39
+ super().__init__(msg)
40
+
41
+
42
+ ResponseType = Union[dict, bytes]
43
+
44
+
45
+ class VeniceClient:
46
+ def __init__(
47
+ self,
48
+ api_key: str,
49
+ base_url: Optional[str] = None,
50
+ timeout: float = 60.0,
51
+ user_agent: str = "venice-cli/0.1",
52
+ ):
53
+ if not api_key:
54
+ raise ValueError("api_key is required")
55
+ self.api_key = api_key
56
+ self.base_url = (base_url or config.DEFAULT_BASE_URL).rstrip("/")
57
+ self.timeout = timeout
58
+ self.user_agent = user_agent
59
+
60
+ def request(
61
+ self,
62
+ method: str,
63
+ path: str,
64
+ *,
65
+ json_body: Optional[dict] = None,
66
+ params: Optional[dict] = None,
67
+ ) -> Tuple[int, str, bytes]:
68
+ url = self.base_url + path
69
+ if params:
70
+ url += "?" + urllib.parse.urlencode(params, doseq=True)
71
+
72
+ headers = {
73
+ "Authorization": f"Bearer {self.api_key}",
74
+ "Accept": "application/json, audio/*, image/*, video/*",
75
+ "User-Agent": self.user_agent,
76
+ }
77
+ data: Optional[bytes] = None
78
+ if json_body is not None:
79
+ data = json.dumps(json_body).encode("utf-8")
80
+ headers["Content-Type"] = "application/json"
81
+
82
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
83
+ try:
84
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
85
+ body = resp.read()
86
+ ctype = resp.headers.get("Content-Type", "")
87
+ status = getattr(resp, "status", 200)
88
+ return status, ctype, body
89
+ except urllib.error.HTTPError as e:
90
+ err_body = b""
91
+ try:
92
+ err_body = e.read()
93
+ except Exception:
94
+ pass
95
+ err_ctype = ""
96
+ try:
97
+ err_ctype = e.headers.get("Content-Type", "")
98
+ except Exception:
99
+ pass
100
+ self._raise_api_error(e.code, url, err_body, err_ctype)
101
+ except urllib.error.URLError as e:
102
+ raise VeniceAPIError(0, url, f"connection error: {e.reason}") from None
103
+
104
+ def post_json(self, path: str, body: dict) -> dict:
105
+ status, ctype, raw = self.request("POST", path, json_body=body)
106
+ return self._decode_json(status, path, ctype, raw)
107
+
108
+ def get_json(self, path: str, params: Optional[dict] = None) -> dict:
109
+ status, ctype, raw = self.request("GET", path, params=params)
110
+ return self._decode_json(status, path, ctype, raw)
111
+
112
+ def post_for_bytes_or_json(
113
+ self, path: str, body: dict
114
+ ) -> Tuple[str, ResponseType]:
115
+ """For endpoints that may return JSON (in-progress) OR binary (done).
116
+
117
+ Used by /audio/retrieve. Returns (content_type, payload):
118
+ - ("audio/mpeg", b"...") on completion
119
+ - ("application/json", {...}) while still processing
120
+ """
121
+ status, ctype, raw = self.request("POST", path, json_body=body)
122
+ ct_low = (ctype or "").lower()
123
+ if ct_low.startswith("application/json"):
124
+ return ctype, (json.loads(raw.decode("utf-8")) if raw else {})
125
+ if (
126
+ ct_low.startswith("audio/")
127
+ or ct_low.startswith("image/")
128
+ or ct_low.startswith("video/")
129
+ ):
130
+ return ctype, raw
131
+ return ctype, raw
132
+
133
+ def poll_retrieve(
134
+ self,
135
+ path: str,
136
+ body: dict,
137
+ *,
138
+ interval: float = config.SFX_POLL_INTERVAL_SEC,
139
+ max_wait: float = config.SFX_POLL_MAX_WAIT_SEC,
140
+ on_tick: Optional[Callable[[dict], None]] = None,
141
+ terminal_statuses: Tuple[str, ...] = (),
142
+ ) -> Tuple[str, ResponseType]:
143
+ """Poll an async endpoint that switches content-type on completion.
144
+
145
+ On success returns (content_type, payload):
146
+ - (ctype, bytes) when the endpoint streams the finished media, or
147
+ - (ctype, dict) when the JSON `status` is in `terminal_statuses`
148
+ (e.g. video's "COMPLETED" -- the media is fetched separately from a
149
+ download_url). Audio callers leave `terminal_statuses` empty and
150
+ always get bytes.
151
+
152
+ Raises VeniceAPIError on terminal HTTP errors or an unexpected status.
153
+ Raises TimeoutError if max_wait elapses while still PROCESSING.
154
+ """
155
+ deadline = time.monotonic() + max_wait
156
+ while True:
157
+ ctype, payload = self.post_for_bytes_or_json(path, body)
158
+ if isinstance(payload, (bytes, bytearray)):
159
+ return ctype, bytes(payload)
160
+ if not isinstance(payload, dict):
161
+ raise VeniceAPIError(
162
+ 0, path, f"unexpected payload type from {path}: {type(payload).__name__}"
163
+ )
164
+ status = payload.get("status")
165
+ if status and status in terminal_statuses:
166
+ return ctype, payload
167
+ if status and status != "PROCESSING":
168
+ raise VeniceAPIError(
169
+ 0, path, f"unexpected status: {payload!r}"
170
+ )
171
+ if on_tick:
172
+ try:
173
+ on_tick(payload)
174
+ except Exception:
175
+ pass
176
+ if time.monotonic() >= deadline:
177
+ raise TimeoutError(
178
+ f"not ready after {max_wait}s "
179
+ f"(last status: {status!r})"
180
+ )
181
+ time.sleep(interval)
182
+
183
+ def get_url_bytes(self, url: str) -> Tuple[str, bytes]:
184
+ """Fetch an arbitrary URL's bytes with a plain GET (no auth header).
185
+
186
+ For presigned download URLs (e.g. a video's download_url), which carry
187
+ their own auth in the query string and must not receive our Bearer token.
188
+ Returns (content_type, bytes); maps HTTP/URL errors to VeniceAPIError.
189
+ """
190
+ req = urllib.request.Request(
191
+ url, headers={"User-Agent": self.user_agent}, method="GET"
192
+ )
193
+ try:
194
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
195
+ return resp.headers.get("Content-Type", ""), resp.read()
196
+ except urllib.error.HTTPError as e:
197
+ err_body = b""
198
+ try:
199
+ err_body = e.read()
200
+ except Exception:
201
+ pass
202
+ self._raise_api_error(e.code, url, err_body, "")
203
+ except urllib.error.URLError as e:
204
+ raise VeniceAPIError(0, url, f"connection error: {e.reason}") from None
205
+
206
+ @staticmethod
207
+ def _decode_json(status: int, path: str, ctype: str, raw: bytes) -> dict:
208
+ if not raw:
209
+ return {}
210
+ try:
211
+ return json.loads(raw.decode("utf-8"))
212
+ except (UnicodeDecodeError, json.JSONDecodeError) as e:
213
+ raise VeniceAPIError(
214
+ status, path, f"non-JSON response ({ctype}): {e}"
215
+ ) from None
216
+
217
+ def get_balance(self) -> Optional[dict]:
218
+ """Fetch current balance + tier via /api_keys/rate_limits.
219
+
220
+ Returns the parsed `data` block (with balances, apiTier, nextEpochBegins,
221
+ rateLimits) or None if the call fails. Best-effort; callers should
222
+ treat None as "balance unavailable, continue".
223
+ """
224
+ try:
225
+ doc = self.get_json("/api_keys/rate_limits")
226
+ except VeniceAPIError:
227
+ return None
228
+ data = doc.get("data") if isinstance(doc, dict) else None
229
+ return data if isinstance(data, dict) else None
230
+
231
+ @staticmethod
232
+ def _raise_api_error(status: int, url: str, body: bytes, ctype: str):
233
+ excerpt = ""
234
+ code: Optional[str] = None
235
+ try:
236
+ text = body.decode("utf-8", errors="replace")
237
+ excerpt = text[:2048]
238
+ if (ctype or "").lower().startswith("application/json"):
239
+ doc: Any = json.loads(text)
240
+ if isinstance(doc, dict):
241
+ code = doc.get("code")
242
+ if not code and isinstance(doc.get("error"), dict):
243
+ code = doc["error"].get("code")
244
+ except Exception:
245
+ pass
246
+ raise VeniceAPIError(status, url, excerpt, code=code)
247
+
248
+
249
+ def build_client_from_auth():
250
+ """Construct a VeniceClient using env-var or file credentials.
251
+
252
+ Raises auth.AuthError if no key is available. Honors $VENICE_BASE_URL.
253
+ """
254
+ from . import auth
255
+
256
+ key = auth.load_key()
257
+ base = os.environ.get(config.ENV_BASE_URL) or config.DEFAULT_BASE_URL
258
+ return VeniceClient(api_key=key, base_url=base)
@@ -0,0 +1,22 @@
1
+ """Single import point. Adding a subcommand = one import + one tuple entry."""
2
+ from . import balance, bg_remove, chat, contact_sheet, embed, image, login, master, models, music, sfx, tts, upscale, video
3
+
4
+
5
+ def register_all(subparsers) -> None:
6
+ login.register(subparsers)
7
+ balance.register(subparsers)
8
+ models.register(subparsers)
9
+ sfx.register(subparsers)
10
+ sfx.register_status(subparsers)
11
+ music.register(subparsers)
12
+ music.register_status(subparsers)
13
+ video.register(subparsers)
14
+ video.register_status(subparsers)
15
+ chat.register(subparsers)
16
+ tts.register(subparsers)
17
+ image.register(subparsers)
18
+ upscale.register(subparsers)
19
+ bg_remove.register(subparsers)
20
+ embed.register(subparsers)
21
+ master.register(subparsers)
22
+ contact_sheet.register(subparsers)
@@ -0,0 +1,96 @@
1
+ """Audio-specific glue over the shared async-queue engine (`_queue`).
2
+
3
+ `sfx` and `music` share this: the endpoint-agnostic quote/queue/poll plumbing
4
+ lives in `_queue`; here we keep the audio content-type map and the
5
+ audio-specialized `retrieve_and_save` (optional post-processing + playback).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Callable, Optional, Tuple
13
+
14
+ from .. import audio_player
15
+ from ..client import VeniceAPIError
16
+ from . import _queue
17
+
18
+ EXT_BY_CTYPE = {
19
+ "audio/mpeg": ".mp3",
20
+ "audio/mp3": ".mp3",
21
+ "audio/wav": ".wav",
22
+ "audio/wave": ".wav",
23
+ "audio/x-wav": ".wav",
24
+ "audio/flac": ".flac",
25
+ }
26
+
27
+
28
+ def ext_for(ctype: str) -> Tuple[str, bool]:
29
+ return _queue.ext_for(ctype, EXT_BY_CTYPE)
30
+
31
+
32
+ def retrieve_and_save(
33
+ client,
34
+ model: str,
35
+ queue_id: str,
36
+ out_arg: Optional[Path],
37
+ poll_interval: float,
38
+ max_wait: float,
39
+ no_cleanup: bool,
40
+ want_play: Optional[bool],
41
+ *,
42
+ name_prefix: str,
43
+ retry_hint: str,
44
+ post_process: Optional[Callable[[Path], int]] = None,
45
+ ) -> int:
46
+ body = {"model": model, "queue_id": queue_id}
47
+ start = time.monotonic()
48
+ try:
49
+ ctype, audio = client.poll_retrieve(
50
+ "/audio/retrieve",
51
+ body,
52
+ interval=poll_interval,
53
+ max_wait=max_wait,
54
+ on_tick=_queue.progress_tick(start),
55
+ )
56
+ except VeniceAPIError as e:
57
+ sys.stderr.write("\n")
58
+ print(f"retrieve failed: {e}", file=sys.stderr)
59
+ return _queue.status_to_exit(e)
60
+ except TimeoutError as e:
61
+ sys.stderr.write("\n")
62
+ print(f"{e}; check later with: {retry_hint}", file=sys.stderr)
63
+ return 7
64
+ sys.stderr.write("\n")
65
+
66
+ ext, unknown = ext_for(ctype)
67
+ if unknown:
68
+ print(f"warning: unexpected content-type {ctype!r}; saving as .bin", file=sys.stderr)
69
+ out_path = _queue.resolve_output_path(out_arg, queue_id, ext, prefix=name_prefix)
70
+
71
+ try:
72
+ out_path.write_bytes(audio)
73
+ except OSError as e:
74
+ print(f"could not write {out_path}: {e}", file=sys.stderr)
75
+ return 9
76
+
77
+ abs_path = out_path.resolve()
78
+ print(str(abs_path))
79
+ print(f"wrote {len(audio)} bytes to {abs_path}", file=sys.stderr)
80
+
81
+ if not no_cleanup:
82
+ try:
83
+ client.post_json("/audio/complete", {"model": model, "queue_id": queue_id})
84
+ except VeniceAPIError as e:
85
+ print(f"warning: cleanup call failed: {e}", file=sys.stderr)
86
+
87
+ post_rc = 0
88
+ if post_process is not None:
89
+ post_rc = post_process(out_path)
90
+
91
+ should_play = want_play
92
+ if should_play is None:
93
+ should_play = sys.stdout.isatty() and audio_player.has_player()
94
+ if should_play:
95
+ audio_player.play(out_path)
96
+ return post_rc
@@ -0,0 +1,83 @@
1
+ """Shared model-catalog resolution for commands that pick a model by id.
2
+
3
+ Extracted from `chat` so it, `embed`, and `video` share one copy of the free
4
+ `/models?type=...` GET plus the default-trait/validation logic rather than each
5
+ carrying its own. These helpers take primitive args (a model type, the requested
6
+ id, a label and noun for messages) so they stay independent of any one command's
7
+ argument shape.
8
+
9
+ The catalog GET is free, so commands call it before the paid request to validate
10
+ `--model` and resolve a default without spending.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import sys
15
+ from typing import List, Optional, Tuple
16
+
17
+ from ..client import VeniceAPIError
18
+
19
+
20
+ def catalog(client, model_type: str) -> Optional[List[dict]]:
21
+ """Fetch the model catalog for `model_type` ("text", "embedding", "video").
22
+
23
+ None if the (free) GET is unavailable, which leaves the caller unable to
24
+ validate or pick a default.
25
+ """
26
+ try:
27
+ doc = client.get_json("/models", params={"type": model_type})
28
+ except VeniceAPIError:
29
+ return None
30
+ data = doc.get("data") if isinstance(doc, dict) else None
31
+ return list(data) if isinstance(data, list) else None
32
+
33
+
34
+ def default_model(models: List[dict]) -> Optional[str]:
35
+ """The id of the first model advertising the 'default' trait, if any."""
36
+ for m in models:
37
+ spec = m.get("model_spec") if isinstance(m, dict) else None
38
+ traits = spec.get("traits") if isinstance(spec, dict) else None
39
+ if isinstance(traits, list) and "default" in traits:
40
+ return m.get("id")
41
+ return None
42
+
43
+
44
+ def resolve_model(
45
+ requested: Optional[str],
46
+ models: Optional[List[dict]],
47
+ *,
48
+ label: str,
49
+ noun: str,
50
+ ) -> Tuple[Optional[str], Optional[int]]:
51
+ """Validate `requested` against the catalog, or pick the default.
52
+
53
+ Returns (model_id, exit_code). exit_code is None on success. `label` prefixes
54
+ error messages (e.g. "chat") and `noun` names the model kind in them (e.g.
55
+ "text model").
56
+ """
57
+ if models is None:
58
+ # Catalog unavailable: can't validate or pick a default.
59
+ if requested:
60
+ return requested, None
61
+ print(
62
+ f"{label}: could not fetch the model catalog; pass --model explicitly",
63
+ file=sys.stderr,
64
+ )
65
+ return None, 2
66
+
67
+ ids = [m.get("id") for m in models if isinstance(m, dict) and m.get("id")]
68
+ if requested:
69
+ if requested in ids:
70
+ return requested, None
71
+ print(f"{label}: unknown {noun} {requested!r}", file=sys.stderr)
72
+ print("available: " + ", ".join(ids), file=sys.stderr)
73
+ return None, 6
74
+
75
+ default = default_model(models)
76
+ if default:
77
+ return default, None
78
+ print(
79
+ f"{label}: no default {noun} advertised; pass --model. "
80
+ "available: " + ", ".join(ids),
81
+ file=sys.stderr,
82
+ )
83
+ return None, 6
@@ -0,0 +1,62 @@
1
+ """Shared `openai` SDK plumbing for Venice's OpenAI-compatible endpoints.
2
+
3
+ Extracted from `chat` so it and `embed` share one copy of the lazy-import probe,
4
+ the SDK client construction, and the exception -> exit-code mapping rather than
5
+ each carrying its own. These helpers take primitive args (a label for messages,
6
+ the imported module, an exception) so they stay independent of any one command's
7
+ argument shape.
8
+
9
+ The SDK is imported lazily -- the rest of the CLI is stdlib-only, so a missing
10
+ `openai` must degrade to a hint and exit 2 rather than break `venice --help`.
11
+ Callers probe the import *first*, before building a client or fetching a
12
+ catalog, so the missing-SDK path never touches the network.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import sys
17
+
18
+
19
+ def import_openai(label: str):
20
+ """Import the openai SDK lazily. None (after printing a hint) if absent.
21
+
22
+ `label` names the command in the hint (e.g. "chat").
23
+ """
24
+ try:
25
+ import openai
26
+ except ImportError:
27
+ print(
28
+ f"venice {label} needs the openai package: "
29
+ 'pip install "venice-cli[openai]" (or: pip install openai)',
30
+ file=sys.stderr,
31
+ )
32
+ return None
33
+ return openai
34
+
35
+
36
+ def build_openai(module, client):
37
+ """Build an SDK client pointed at Venice, borrowing the lean client's auth."""
38
+ return module.OpenAI(api_key=client.api_key, base_url=client.base_url)
39
+
40
+
41
+ def status_to_exit(module, e, label: str) -> int:
42
+ """Map an openai SDK exception to a venice exit code.
43
+
44
+ `module` is the imported openai module (for its exception types) and `label`
45
+ prefixes the message (e.g. "chat").
46
+ """
47
+ if isinstance(e, module.APIConnectionError):
48
+ print(f"{label}: connection error: {e}", file=sys.stderr)
49
+ return 8
50
+ status = getattr(e, "status_code", None)
51
+ print(f"{label}: API error: {e}", file=sys.stderr)
52
+ if status == 401:
53
+ return 2
54
+ if status == 404:
55
+ return 6
56
+ if status == 429:
57
+ return 4
58
+ if isinstance(status, int) and 500 <= status < 600:
59
+ return 5
60
+ if isinstance(status, int) and 400 <= status < 500:
61
+ return 2
62
+ return 5
@@ -0,0 +1,87 @@
1
+ """Shared async-queue engine for Venice's quote -> queue -> poll -> retrieve
2
+ -> complete endpoints.
3
+
4
+ Endpoint-agnostic primitives factored out of `_audio` so audio (`sfx`/`music`)
5
+ and video can share one copy of the plumbing. Callers pass primitive args
6
+ (model, queue_id, paths, an extension map) so these helpers stay independent of
7
+ any one command's argument shape.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ import time
13
+ from pathlib import Path
14
+ from typing import Optional, Tuple
15
+
16
+ from .. import auth
17
+ from ..client import VeniceAPIError, build_client_from_auth
18
+
19
+
20
+ def build_client():
21
+ try:
22
+ return build_client_from_auth(), 0
23
+ except auth.AuthError as e:
24
+ print(str(e), file=sys.stderr)
25
+ return None, 2
26
+
27
+
28
+ def ext_for(ctype: str, ext_map: dict, default: str = ".bin") -> Tuple[str, bool]:
29
+ """Map a response content-type to a file extension.
30
+
31
+ Returns (ext, unknown): `unknown` is True when the type wasn't in the map,
32
+ in which case `default` is returned so the caller can warn.
33
+ """
34
+ base = (ctype or "").split(";", 1)[0].strip().lower()
35
+ if base in ext_map:
36
+ return ext_map[base], False
37
+ return default, True
38
+
39
+
40
+ def resolve_output_path(
41
+ arg_output: Optional[Path], queue_id: str, ext: str, *, prefix: str
42
+ ) -> Path:
43
+ short = (queue_id or "unknown")[:8]
44
+ default_name = f"{prefix}-{short}{ext}"
45
+ if arg_output is None:
46
+ return Path.cwd() / default_name
47
+ if arg_output.is_dir():
48
+ return arg_output / default_name
49
+ return arg_output
50
+
51
+
52
+ def status_to_exit(err: VeniceAPIError) -> int:
53
+ s = err.status
54
+ if s == 422:
55
+ return 3
56
+ if s == 429:
57
+ return 4
58
+ if 500 <= s < 600:
59
+ return 5
60
+ if s == 404:
61
+ return 6
62
+ if s == 0:
63
+ return 8
64
+ return 2
65
+
66
+
67
+ def progress_tick(start: float):
68
+ def _on(payload: dict) -> None:
69
+ avg_ms = payload.get("average_execution_time") or 0
70
+ elapsed_ms = payload.get("execution_duration") or 0
71
+ try:
72
+ avg_s = float(avg_ms) / 1000.0
73
+ except (TypeError, ValueError):
74
+ avg_s = 0.0
75
+ try:
76
+ el_s = float(elapsed_ms) / 1000.0
77
+ except (TypeError, ValueError):
78
+ el_s = 0.0
79
+ wall = time.monotonic() - start
80
+ if avg_s > 0:
81
+ sys.stderr.write(
82
+ f"\r[wall {wall:5.1f}s | server {el_s:5.1f}s / ~{avg_s:5.1f}s] processing..."
83
+ )
84
+ else:
85
+ sys.stderr.write(f"\r[wall {wall:5.1f}s] processing...")
86
+ sys.stderr.flush()
87
+ return _on