openvidia 2.2.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.
- openvidia/__init__.py +30 -0
- openvidia/__main__.py +827 -0
- openvidia/_upstream_utils.py +57 -0
- openvidia/anthropic_shim.py +714 -0
- openvidia/compaction.py +851 -0
- openvidia/config.py +513 -0
- openvidia/embedding_cache.py +69 -0
- openvidia/graph_engine.py +466 -0
- openvidia/provider_fallback.py +98 -0
- openvidia/proxy_app.py +863 -0
- openvidia/proxy_state.py +1010 -0
- openvidia/redis_sync.py +93 -0
- openvidia/responses_shim.py +1374 -0
- openvidia/safe_file.py +100 -0
- openvidia/server_manager.py +106 -0
- openvidia/upstream_router.py +50 -0
- openvidia/web/assets/32x32.ico +0 -0
- openvidia/web/assets/64x64.ico +0 -0
- openvidia/web/assets/dashboard.gif +0 -0
- openvidia/web/assets/favicon.ico +0 -0
- openvidia/web/assets/logo.png +0 -0
- openvidia/web/favicon.ico +0 -0
- openvidia/web/index.html +166 -0
- openvidia/web/logo.png +0 -0
- openvidia/web/main.js +732 -0
- openvidia/web/styles.css +425 -0
- openvidia/webui.py +532 -0
- openvidia-2.2.0.dist-info/METADATA +814 -0
- openvidia-2.2.0.dist-info/RECORD +33 -0
- openvidia-2.2.0.dist-info/WHEEL +5 -0
- openvidia-2.2.0.dist-info/entry_points.txt +2 -0
- openvidia-2.2.0.dist-info/licenses/LICENSE +21 -0
- openvidia-2.2.0.dist-info/top_level.txt +1 -0
openvidia/__main__.py
ADDED
|
@@ -0,0 +1,827 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenVidia — minimal multi-key NVIDIA API proxy with desktop app.
|
|
3
|
+
|
|
4
|
+
Install:
|
|
5
|
+
pip install -e .
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
openvidia # start proxy + desktop window
|
|
9
|
+
openvidia foreground # foreground mode (logs stdout)
|
|
10
|
+
openvidia setup # configure opencode + Jcode (the persistent edits)
|
|
11
|
+
openvidia run <cli> [args] # run any CLI against the proxy, writing nothing
|
|
12
|
+
|
|
13
|
+
Dashboard + API at http://localhost:1919
|
|
14
|
+
Edit keys via ~/.config/openvidia/keys.json or dashboard Keys tab.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import shutil
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from . import config
|
|
25
|
+
from .proxy_state import ProxyStats
|
|
26
|
+
from .server_manager import start
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Configuration — entrypoint constants
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
PORT = 1919
|
|
32
|
+
ENV_VAR = "OPENVIDIA_API_KEY"
|
|
33
|
+
ENV_VAL = "ignored"
|
|
34
|
+
_tray_ref = None # Global tray reference (anti-GC)
|
|
35
|
+
_tray_hide = None # Global hide-function reference for close-to-tray
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _port_listeners(port: int) -> list:
|
|
39
|
+
"""Processes currently LISTENing on ``port`` (never includes ourselves)."""
|
|
40
|
+
try:
|
|
41
|
+
import psutil
|
|
42
|
+
except ImportError:
|
|
43
|
+
return []
|
|
44
|
+
me = os.getpid()
|
|
45
|
+
out = {}
|
|
46
|
+
try:
|
|
47
|
+
conns = psutil.net_connections(kind="inet")
|
|
48
|
+
except (psutil.AccessDenied, OSError):
|
|
49
|
+
return []
|
|
50
|
+
for conn in conns:
|
|
51
|
+
try:
|
|
52
|
+
if (
|
|
53
|
+
conn.laddr
|
|
54
|
+
and conn.laddr.port == port
|
|
55
|
+
and conn.status == "LISTEN"
|
|
56
|
+
and conn.pid
|
|
57
|
+
and conn.pid != me
|
|
58
|
+
):
|
|
59
|
+
out[conn.pid] = psutil.Process(conn.pid)
|
|
60
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied, AttributeError):
|
|
61
|
+
continue
|
|
62
|
+
return list(out.values())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _kill_stale_port(port: int, *, grace: float = 6.0, hard: float = 4.0) -> bool:
|
|
66
|
+
"""Free ``port``, escalating SIGTERM → SIGKILL. Returns True if it is free.
|
|
67
|
+
|
|
68
|
+
SIGTERM alone is not enough: uvicorn shuts down gracefully, and an agent
|
|
69
|
+
CLI holding an open SSE stream keeps it alive indefinitely. The previous
|
|
70
|
+
version sent SIGTERM, waited 3s, then returned silently either way — so
|
|
71
|
+
the new instance started next to a survivor that still owned the port,
|
|
72
|
+
and every request kept being served by the OLD code.
|
|
73
|
+
"""
|
|
74
|
+
import time as _time
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
import psutil
|
|
78
|
+
except ImportError:
|
|
79
|
+
return True
|
|
80
|
+
|
|
81
|
+
procs = _port_listeners(port)
|
|
82
|
+
if not procs:
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
names = ", ".join(f"{p.name()}({p.pid})" for p in procs)
|
|
86
|
+
for p in procs:
|
|
87
|
+
try:
|
|
88
|
+
p.terminate()
|
|
89
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
90
|
+
pass
|
|
91
|
+
_gone, alive = psutil.wait_procs(procs, timeout=grace)
|
|
92
|
+
if alive:
|
|
93
|
+
for p in alive:
|
|
94
|
+
try:
|
|
95
|
+
p.kill() # SIGKILL — it ignored the polite request
|
|
96
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
97
|
+
pass
|
|
98
|
+
psutil.wait_procs(alive, timeout=hard)
|
|
99
|
+
|
|
100
|
+
# The socket can outlive the process briefly; wait for it to be released.
|
|
101
|
+
deadline = _time.monotonic() + hard
|
|
102
|
+
while _time.monotonic() < deadline:
|
|
103
|
+
if not _port_listeners(port):
|
|
104
|
+
print(f"● Freed port {port} (was: {names})", flush=True)
|
|
105
|
+
return True
|
|
106
|
+
_time.sleep(0.1)
|
|
107
|
+
|
|
108
|
+
print(
|
|
109
|
+
f"✗ Port {port} is STILL held by {', '.join(f'{p.name()}({p.pid})' for p in _port_listeners(port))} "
|
|
110
|
+
f"— refusing to start a second instance on top of it",
|
|
111
|
+
flush=True,
|
|
112
|
+
)
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _setup_opencode():
|
|
117
|
+
"""Configure the opencode CLI (~/.config/opencode/opencode.json) to use OpenVidia."""
|
|
118
|
+
oc_path = config.opencode_config_path()
|
|
119
|
+
if not oc_path.exists():
|
|
120
|
+
print(f"ℹ opencode not found at {oc_path} — skipping")
|
|
121
|
+
return False
|
|
122
|
+
try:
|
|
123
|
+
cfg = json.loads(oc_path.read_text())
|
|
124
|
+
except (json.JSONDecodeError, OSError):
|
|
125
|
+
print(f"✗ Invalid opencode config at {oc_path}")
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
changed = False
|
|
129
|
+
providers = cfg.setdefault("provider", {})
|
|
130
|
+
|
|
131
|
+
# Remove the orphan nvidia provider if it points to localhost
|
|
132
|
+
nv = providers.get("nvidia", {})
|
|
133
|
+
if isinstance(nv, dict) and nv.get("options", {}).get("baseURL", "").startswith(
|
|
134
|
+
"http://localhost"
|
|
135
|
+
):
|
|
136
|
+
del providers["nvidia"]
|
|
137
|
+
changed = True
|
|
138
|
+
|
|
139
|
+
if "openvidia" not in providers:
|
|
140
|
+
providers["openvidia"] = {
|
|
141
|
+
"models": {"openvidia": {"name": "OpenVidia", "tools": True}},
|
|
142
|
+
"npm": "@ai-sdk/openai-compatible",
|
|
143
|
+
"options": {
|
|
144
|
+
"apiKey": "ignored",
|
|
145
|
+
"baseURL": f"http://localhost:{PORT}/v1",
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
changed = True
|
|
149
|
+
print("✓ Added OpenVidia provider to opencode")
|
|
150
|
+
else:
|
|
151
|
+
ov = providers["openvidia"]
|
|
152
|
+
m = ov.setdefault("models", {})
|
|
153
|
+
if "openvidia" not in m:
|
|
154
|
+
m["openvidia"] = {"name": "OpenVidia", "tools": True}
|
|
155
|
+
changed = True
|
|
156
|
+
print("✓ Added OpenVidia model to opencode provider")
|
|
157
|
+
|
|
158
|
+
# Auto-compaction, but only as a default. Forcing it back on every run
|
|
159
|
+
# overrode users who had deliberately turned it off.
|
|
160
|
+
if "compaction" not in cfg:
|
|
161
|
+
cfg["compaction"] = {"auto": True, "prune": True, "reserved": 8000}
|
|
162
|
+
changed = True
|
|
163
|
+
print("✓ Enabled auto-compaction (prune=true, reserved=8000)")
|
|
164
|
+
|
|
165
|
+
# Select the provider only when nothing is selected. This used to be
|
|
166
|
+
# unconditional and ran on every proxy start, so a user who picked another
|
|
167
|
+
# provider in opencode found it reset the next time OpenVidia launched.
|
|
168
|
+
if not cfg.get("model"):
|
|
169
|
+
cfg["model"] = "openvidia/openvidia"
|
|
170
|
+
changed = True
|
|
171
|
+
print("✓ Default model set to openvidia/openvidia")
|
|
172
|
+
elif cfg.get("model") != "openvidia/openvidia":
|
|
173
|
+
print(f"ℹ Leaving your opencode model as {cfg['model']} — switch with /model openvidia")
|
|
174
|
+
|
|
175
|
+
if not cfg.get("small_model"):
|
|
176
|
+
cfg["small_model"] = "openvidia/openvidia"
|
|
177
|
+
changed = True
|
|
178
|
+
print("✓ Small model set to openvidia/openvidia")
|
|
179
|
+
|
|
180
|
+
# NOTE: the previous version also prepended Path.cwd()/AGENTS.md to
|
|
181
|
+
# opencode's GLOBAL instructions. Launched from this repo — which the
|
|
182
|
+
# desktop launcher does — that meant every opencode project everywhere
|
|
183
|
+
# started loading OpenVidia's own AGENTS.md. A proxy has no business
|
|
184
|
+
# deciding what instructions another tool reads.
|
|
185
|
+
|
|
186
|
+
if changed:
|
|
187
|
+
# Someone else's config file: back it up before touching it. This is
|
|
188
|
+
# the protection save_keys_file already gave OpenVidia's own data.
|
|
189
|
+
try:
|
|
190
|
+
from .safe_file import create_backup
|
|
191
|
+
|
|
192
|
+
create_backup(oc_path)
|
|
193
|
+
except OSError as e:
|
|
194
|
+
print(f"✗ could not back up {oc_path}: {e} — not writing")
|
|
195
|
+
return False
|
|
196
|
+
config.atomic_write(oc_path, json.dumps(cfg, indent=2), mode=0o644)
|
|
197
|
+
|
|
198
|
+
print(f"✓ OpenVidia provider ready → http://localhost:{PORT}/v1")
|
|
199
|
+
print(f"✓ Dashboard at http://localhost:{PORT}")
|
|
200
|
+
return True
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _setup_jcode():
|
|
204
|
+
"""Configure Jcode (~/.jcode/config.toml) to use OpenVidia as a provider.
|
|
205
|
+
|
|
206
|
+
Jcode is an open-source coding agent (https://github.com/1jehuang/jcode)
|
|
207
|
+
that supports OpenAI-compatible providers via TOML config. This adds the
|
|
208
|
+
``openvidia`` provider block and sets it as default — but only when no
|
|
209
|
+
provider is configured yet, so a user who picked another provider keeps it.
|
|
210
|
+
|
|
211
|
+
Uses tomlkit to preserve existing comments and formatting.
|
|
212
|
+
"""
|
|
213
|
+
jc_path = config.jcode_config_path()
|
|
214
|
+
if not jc_path.parent.exists():
|
|
215
|
+
print(f"ℹ Jcode not found ({jc_path.parent} does not exist) — skipping")
|
|
216
|
+
return False
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
import tomlkit
|
|
220
|
+
except ImportError:
|
|
221
|
+
print("ℹ tomlkit not installed — skipping Jcode setup")
|
|
222
|
+
return False
|
|
223
|
+
|
|
224
|
+
# Read existing config (or start fresh)
|
|
225
|
+
if jc_path.exists():
|
|
226
|
+
try:
|
|
227
|
+
from .safe_file import create_backup
|
|
228
|
+
|
|
229
|
+
create_backup(jc_path)
|
|
230
|
+
except OSError as e:
|
|
231
|
+
print(f"✗ could not back up {jc_path}: {e} — not writing")
|
|
232
|
+
return False
|
|
233
|
+
try:
|
|
234
|
+
doc = tomlkit.parse(jc_path.read_text())
|
|
235
|
+
except Exception:
|
|
236
|
+
print(f"✗ Invalid Jcode config at {jc_path} — skipping")
|
|
237
|
+
return False
|
|
238
|
+
else:
|
|
239
|
+
doc = tomlkit.document()
|
|
240
|
+
|
|
241
|
+
changed = False
|
|
242
|
+
|
|
243
|
+
# ── Provider block: [providers.openvidia] ──────────────────────────
|
|
244
|
+
providers = doc.get("providers", None)
|
|
245
|
+
if providers is None:
|
|
246
|
+
providers = tomlkit.table()
|
|
247
|
+
doc["providers"] = providers
|
|
248
|
+
changed = True
|
|
249
|
+
|
|
250
|
+
if "openvidia" not in providers:
|
|
251
|
+
ov = tomlkit.table()
|
|
252
|
+
ov["type"] = "openai-compatible"
|
|
253
|
+
ov["base_url"] = BASE_URL
|
|
254
|
+
ov["auth"] = "none"
|
|
255
|
+
ov["default_model"] = "openvidia"
|
|
256
|
+
ov["requires_api_key"] = False
|
|
257
|
+
ov["model_catalog"] = True
|
|
258
|
+
ov["stream_idle_timeout_secs"] = 300
|
|
259
|
+
|
|
260
|
+
# Model entries
|
|
261
|
+
models = tomlkit.aot()
|
|
262
|
+
m1 = tomlkit.table()
|
|
263
|
+
m1["id"] = "openvidia"
|
|
264
|
+
m1["context_window"] = 202752
|
|
265
|
+
models.append(m1)
|
|
266
|
+
ov["models"] = models
|
|
267
|
+
|
|
268
|
+
providers["openvidia"] = ov
|
|
269
|
+
changed = True
|
|
270
|
+
print("✓ Added OpenVidia provider to Jcode")
|
|
271
|
+
else:
|
|
272
|
+
# Ensure base_url is current (port could have changed)
|
|
273
|
+
ov = providers["openvidia"]
|
|
274
|
+
if ov.get("base_url") != BASE_URL:
|
|
275
|
+
ov["base_url"] = BASE_URL
|
|
276
|
+
changed = True
|
|
277
|
+
print("✓ Updated OpenVidia base_url in Jcode")
|
|
278
|
+
|
|
279
|
+
# ── Default provider: only when unset ──────────────────────────────
|
|
280
|
+
provider_section = doc.get("provider", None)
|
|
281
|
+
if provider_section is None:
|
|
282
|
+
provider_section = tomlkit.table()
|
|
283
|
+
doc["provider"] = provider_section
|
|
284
|
+
changed = True
|
|
285
|
+
|
|
286
|
+
current_default = provider_section.get("default_provider")
|
|
287
|
+
if not current_default:
|
|
288
|
+
provider_section["default_provider"] = "openvidia"
|
|
289
|
+
changed = True
|
|
290
|
+
print("✓ Default provider set to openvidia")
|
|
291
|
+
elif current_default != "openvidia":
|
|
292
|
+
print(f"ℹ Leaving your Jcode default provider as {current_default}")
|
|
293
|
+
|
|
294
|
+
current_model = provider_section.get("default_model")
|
|
295
|
+
if not current_model:
|
|
296
|
+
provider_section["default_model"] = "openvidia"
|
|
297
|
+
changed = True
|
|
298
|
+
print("✓ Default model set to openvidia")
|
|
299
|
+
|
|
300
|
+
if changed:
|
|
301
|
+
config.atomic_write(jc_path, tomlkit.dumps(doc), mode=0o644)
|
|
302
|
+
|
|
303
|
+
print(f"✓ OpenVidia provider ready in Jcode → {jc_path}")
|
|
304
|
+
return True
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _setup_proxy_config():
|
|
308
|
+
"""Ensure ~/.config/openvidia/proxy_config.json exists with default settings."""
|
|
309
|
+
cfg_dir = config.config_dir()
|
|
310
|
+
p = cfg_dir / "proxy_config.json"
|
|
311
|
+
if not p.exists():
|
|
312
|
+
default_cfg = {
|
|
313
|
+
"outbound_proxy": "",
|
|
314
|
+
"comment": "Set outbound_proxy (e.g. http://user:pass@proxy.example.com:8080) for IP rotation across large key pools",
|
|
315
|
+
}
|
|
316
|
+
config.atomic_write(p, json.dumps(default_cfg, indent=2))
|
|
317
|
+
print(f"✓ Created proxy config template → {p}")
|
|
318
|
+
else:
|
|
319
|
+
print(f"✓ Proxy config ready → {p}")
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# ---------------------------------------------------------------------------
|
|
323
|
+
# Running another CLI against the proxy
|
|
324
|
+
# ---------------------------------------------------------------------------
|
|
325
|
+
# The proxy used to reconfigure opencode, Codex, Claude Code and Grok on every
|
|
326
|
+
# single start — not just on `setup`. That reset the user's chosen opencode
|
|
327
|
+
# model to openvidia/openvidia on each launch, re-enabled compaction settings
|
|
328
|
+
# they had turned off, rewrote ~/.codex/config.toml and ~/.grok/config.toml
|
|
329
|
+
# without a backup, appended to shell rc files (with bash syntax, even under
|
|
330
|
+
# fish, so every new shell printed an error), and injected whatever AGENTS.md
|
|
331
|
+
# happened to be in the launch directory into opencode's GLOBAL instructions.
|
|
332
|
+
#
|
|
333
|
+
# `openvidia run <cli>` replaces all of that with the ollama model: nothing on
|
|
334
|
+
# disk changes, ever. The environment is set for one child process and the CLI
|
|
335
|
+
# is exec'd into it.
|
|
336
|
+
BASE_URL = f"http://localhost:{PORT}/v1"
|
|
337
|
+
ROOT_URL = f"http://localhost:{PORT}"
|
|
338
|
+
|
|
339
|
+
# Defaults ship for the variables these tools document. The table is
|
|
340
|
+
# user-editable at ~/.config/openvidia/cli_targets.json so a CLI that renames
|
|
341
|
+
# its variables tomorrow is a config edit rather than a release — the same
|
|
342
|
+
# choice model_options.json already makes for payload flags.
|
|
343
|
+
_DEFAULT_CLI_TARGETS: dict[str, dict] = {
|
|
344
|
+
"opencode": {
|
|
345
|
+
"argv": ["opencode"],
|
|
346
|
+
"env": {"OPENAI_BASE_URL": BASE_URL, "OPENAI_API_KEY": ENV_VAL, ENV_VAR: ENV_VAL},
|
|
347
|
+
},
|
|
348
|
+
"claude": {
|
|
349
|
+
"argv": ["claude"],
|
|
350
|
+
"env": {"ANTHROPIC_BASE_URL": ROOT_URL, "ANTHROPIC_API_KEY": ENV_VAL},
|
|
351
|
+
},
|
|
352
|
+
"codex": {
|
|
353
|
+
"argv": ["codex"],
|
|
354
|
+
"env": {"OPENAI_BASE_URL": BASE_URL, "OPENAI_API_KEY": ENV_VAL, ENV_VAR: ENV_VAL},
|
|
355
|
+
# Codex resolves providers from ~/.codex/config.toml, so the base URL
|
|
356
|
+
# alone may not be enough. We refuse to write that file behind the
|
|
357
|
+
# user's back; print it instead and let them decide.
|
|
358
|
+
"note": (
|
|
359
|
+
"Codex may also need a provider block in ~/.codex/config.toml.\n"
|
|
360
|
+
" If it does not pick up the proxy, add once:\n"
|
|
361
|
+
"\n"
|
|
362
|
+
' model = "openvidia"\n'
|
|
363
|
+
' model_provider = "openvidia"\n'
|
|
364
|
+
"\n"
|
|
365
|
+
" [model_providers.openvidia]\n"
|
|
366
|
+
' name = "OpenVidia"\n'
|
|
367
|
+
f' base_url = "{BASE_URL}"\n'
|
|
368
|
+
f' env_key = "{ENV_VAR}"\n'
|
|
369
|
+
' wire_api = "responses"'
|
|
370
|
+
),
|
|
371
|
+
},
|
|
372
|
+
"grok": {
|
|
373
|
+
"argv": ["grok"],
|
|
374
|
+
"env": {"OPENAI_BASE_URL": BASE_URL, "OPENAI_API_KEY": ENV_VAL, ENV_VAR: ENV_VAL},
|
|
375
|
+
},
|
|
376
|
+
"jcode": {
|
|
377
|
+
"argv": ["jcode"],
|
|
378
|
+
"env": {"OPENAI_BASE_URL": BASE_URL, "OPENAI_API_KEY": ENV_VAL, ENV_VAR: ENV_VAL},
|
|
379
|
+
},
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _cli_targets() -> dict[str, dict]:
|
|
384
|
+
"""Ship defaults, let the user override them without a release."""
|
|
385
|
+
targets = {k: dict(v) for k, v in _DEFAULT_CLI_TARGETS.items()}
|
|
386
|
+
try:
|
|
387
|
+
p = config.config_dir() / "cli_targets.json"
|
|
388
|
+
if p.exists():
|
|
389
|
+
user = json.loads(p.read_text())
|
|
390
|
+
if isinstance(user, dict):
|
|
391
|
+
for name, spec in user.items():
|
|
392
|
+
if isinstance(spec, dict):
|
|
393
|
+
targets[str(name)] = {**targets.get(str(name), {}), **spec}
|
|
394
|
+
except (json.JSONDecodeError, OSError):
|
|
395
|
+
pass
|
|
396
|
+
return targets
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _run_cmd(argv: list[str]) -> int:
|
|
400
|
+
"""``openvidia run <cli> [args...]`` — point one process at the proxy.
|
|
401
|
+
|
|
402
|
+
Nothing is written. The variables live in this process's child and die
|
|
403
|
+
with it, so a CLI configured against the proxy today is unmodified
|
|
404
|
+
tomorrow, and the user's own config keeps whatever they put in it.
|
|
405
|
+
"""
|
|
406
|
+
targets = _cli_targets()
|
|
407
|
+
if not argv:
|
|
408
|
+
print("Usage: openvidia run <cli> [args...]")
|
|
409
|
+
print(f"Known: {', '.join(sorted(targets))}")
|
|
410
|
+
print("Anything else is executed as-is with the OpenAI-compatible vars set.")
|
|
411
|
+
return 2
|
|
412
|
+
|
|
413
|
+
name, rest = argv[0], argv[1:]
|
|
414
|
+
spec = targets.get(name)
|
|
415
|
+
if spec is None:
|
|
416
|
+
# Not in the table: still useful — most tools read OPENAI_BASE_URL.
|
|
417
|
+
spec = {
|
|
418
|
+
"argv": [name],
|
|
419
|
+
"env": {"OPENAI_BASE_URL": BASE_URL, "OPENAI_API_KEY": ENV_VAL, ENV_VAR: ENV_VAL},
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
env = {**os.environ, **{str(k): str(v) for k, v in (spec.get("env") or {}).items()}}
|
|
423
|
+
cmd = list(spec.get("argv") or [name]) + rest
|
|
424
|
+
|
|
425
|
+
exe = shutil.which(cmd[0])
|
|
426
|
+
if exe is None:
|
|
427
|
+
print(f"✗ {cmd[0]} is not on PATH")
|
|
428
|
+
return 127
|
|
429
|
+
|
|
430
|
+
if not _proxy_is_up(PORT):
|
|
431
|
+
print(f"⚠ Nothing is serving {ROOT_URL} — start it with `openvidia` first.")
|
|
432
|
+
|
|
433
|
+
for k in sorted(spec.get("env") or {}):
|
|
434
|
+
print(f" {k}={spec['env'][k]}")
|
|
435
|
+
if spec.get("note"):
|
|
436
|
+
print(f"\nℹ {spec['note']}\n")
|
|
437
|
+
print(f"→ exec {' '.join(cmd)}\n", flush=True)
|
|
438
|
+
|
|
439
|
+
try:
|
|
440
|
+
os.execvpe(exe, cmd, env)
|
|
441
|
+
except OSError as e: # pragma: no cover — execvpe only returns on failure
|
|
442
|
+
print(f"✗ could not exec {cmd[0]}: {e}")
|
|
443
|
+
return 126
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _proxy_is_up(port: int) -> bool:
|
|
447
|
+
import socket
|
|
448
|
+
|
|
449
|
+
try:
|
|
450
|
+
with socket.create_connection(("127.0.0.1", port), timeout=0.5):
|
|
451
|
+
return True
|
|
452
|
+
except OSError:
|
|
453
|
+
return False
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _setup_cmd():
|
|
457
|
+
"""``openvidia setup`` — configure opencode and Jcode.
|
|
458
|
+
|
|
459
|
+
opencode and Jcode are the two CLIs this project treats as first-class,
|
|
460
|
+
so they get a persistent config edit on setup. Everything else runs
|
|
461
|
+
through `openvidia run`, which writes nothing.
|
|
462
|
+
"""
|
|
463
|
+
print("OpenVidia setup")
|
|
464
|
+
print()
|
|
465
|
+
_setup_proxy_config()
|
|
466
|
+
print()
|
|
467
|
+
_setup_opencode()
|
|
468
|
+
print()
|
|
469
|
+
_setup_jcode()
|
|
470
|
+
print()
|
|
471
|
+
print(f"Proxy: {BASE_URL}")
|
|
472
|
+
print(f"Dashboard: {ROOT_URL}")
|
|
473
|
+
print()
|
|
474
|
+
print("Other CLIs need no setup — run them against the proxy on demand:")
|
|
475
|
+
for name in sorted(n for n in _cli_targets() if n not in ("opencode", "jcode")):
|
|
476
|
+
print(f" openvidia run {name}")
|
|
477
|
+
sys.exit(0)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
async def main_async():
|
|
481
|
+
"""Foreground entrypoint: start the proxy on the event loop."""
|
|
482
|
+
if not _kill_stale_port(PORT):
|
|
483
|
+
# Starting anyway would leave the OLD process serving every request
|
|
484
|
+
# while this one silently fails to bind — the hardest class of bug to
|
|
485
|
+
# diagnose, because the code on disk is not the code answering.
|
|
486
|
+
print(f" Free it manually: fuser -k {PORT}/tcp", flush=True)
|
|
487
|
+
sys.exit(1)
|
|
488
|
+
# Deliberately no _setup_*() here. Starting a proxy is not consent to
|
|
489
|
+
# rewrite four other tools' configuration files.
|
|
490
|
+
repaired = config.harden_config_permissions()
|
|
491
|
+
if repaired:
|
|
492
|
+
print(f" ✓ tightened permissions on {', '.join(repaired)} (was world-readable)")
|
|
493
|
+
keys = config.load_saved_keys_file()
|
|
494
|
+
if not keys:
|
|
495
|
+
print("✗ No keys found. Add keys to ~/.config/openvidia/keys.json")
|
|
496
|
+
print(" Or run: openvidia setup")
|
|
497
|
+
sys.exit(1)
|
|
498
|
+
|
|
499
|
+
stats = ProxyStats()
|
|
500
|
+
saved_model = config.load_active_model()
|
|
501
|
+
|
|
502
|
+
def log(msg: str):
|
|
503
|
+
print(msg, flush=True)
|
|
504
|
+
|
|
505
|
+
web_dir = Path(__file__).resolve().parent / "web"
|
|
506
|
+
srv = await start(
|
|
507
|
+
PORT,
|
|
508
|
+
keys,
|
|
509
|
+
log,
|
|
510
|
+
stats,
|
|
511
|
+
web_dir=web_dir,
|
|
512
|
+
initial_model=saved_model,
|
|
513
|
+
)
|
|
514
|
+
srv.state.log_cb(f"● OpenVidia running on :{PORT} ({len(keys)} keys)")
|
|
515
|
+
|
|
516
|
+
# foreground = logs only, no UI
|
|
517
|
+
try:
|
|
518
|
+
while True:
|
|
519
|
+
await asyncio.sleep(3600)
|
|
520
|
+
except asyncio.CancelledError:
|
|
521
|
+
pass
|
|
522
|
+
finally:
|
|
523
|
+
if srv:
|
|
524
|
+
await srv.shutdown()
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _wait_until_serving(port: int, child=None, timeout: float = 20.0) -> bool:
|
|
528
|
+
"""Block until the proxy answers on ``port``; report why if it never does."""
|
|
529
|
+
import socket
|
|
530
|
+
import time as _time
|
|
531
|
+
|
|
532
|
+
deadline = _time.monotonic() + timeout
|
|
533
|
+
while _time.monotonic() < deadline:
|
|
534
|
+
if child is not None and child.poll() is not None:
|
|
535
|
+
print(
|
|
536
|
+
f"✗ Proxy exited during startup (code {child.returncode}). "
|
|
537
|
+
f"Run `openvidia foreground` to see the error.",
|
|
538
|
+
flush=True,
|
|
539
|
+
)
|
|
540
|
+
return False
|
|
541
|
+
try:
|
|
542
|
+
with socket.create_connection(("127.0.0.1", port), timeout=0.5):
|
|
543
|
+
return True
|
|
544
|
+
except OSError:
|
|
545
|
+
_time.sleep(0.2)
|
|
546
|
+
print(f"✗ Proxy did not start listening on :{port} within {timeout:.0f}s", flush=True)
|
|
547
|
+
return False
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _kill_proxy_by_port(port: int) -> None:
|
|
551
|
+
"""Kill the process listening on the port — used by the tray Quit action.
|
|
552
|
+
|
|
553
|
+
Shares the SIGTERM→SIGKILL escalation with startup: a polite terminate
|
|
554
|
+
alone leaves the proxy alive whenever a client is holding a stream open,
|
|
555
|
+
so "Quit" looked like it worked while the port stayed busy.
|
|
556
|
+
"""
|
|
557
|
+
try:
|
|
558
|
+
_kill_stale_port(port)
|
|
559
|
+
except Exception: # noqa: BLE001 — Quit must never raise
|
|
560
|
+
pass
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _make_signaller(icon_path, window, port):
|
|
564
|
+
"""Create a QObject in the main thread with a 'create' signal.
|
|
565
|
+
|
|
566
|
+
The cross-thread emit is dispatched onto the Qt main loop via
|
|
567
|
+
QueuedConnection, so _create_tray runs in the right thread.
|
|
568
|
+
"""
|
|
569
|
+
try:
|
|
570
|
+
from PyQt6.QtCore import QObject, pyqtSignal
|
|
571
|
+
except ImportError:
|
|
572
|
+
return None
|
|
573
|
+
|
|
574
|
+
class _Sig(QObject):
|
|
575
|
+
create = pyqtSignal()
|
|
576
|
+
|
|
577
|
+
s = _Sig()
|
|
578
|
+
s.create.connect(lambda: _create_tray(icon_path, window, port))
|
|
579
|
+
return s
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _tray_waiter_factory(signaller, window):
|
|
583
|
+
"""Build the function passed to webview.start(func=...).
|
|
584
|
+
|
|
585
|
+
1) Wait for the QCoreApplication (pywebview creates it)
|
|
586
|
+
2) Wait for the window shown event
|
|
587
|
+
3) Emit the cross-thread signal -> tray on the Qt main loop
|
|
588
|
+
"""
|
|
589
|
+
import time as _time
|
|
590
|
+
|
|
591
|
+
def _waiter():
|
|
592
|
+
from PyQt6.QtCore import QCoreApplication
|
|
593
|
+
|
|
594
|
+
for _ in range(100):
|
|
595
|
+
if QCoreApplication.instance() is not None:
|
|
596
|
+
break
|
|
597
|
+
_time.sleep(0.2)
|
|
598
|
+
else:
|
|
599
|
+
return
|
|
600
|
+
|
|
601
|
+
if not window.events.shown.wait(20):
|
|
602
|
+
return
|
|
603
|
+
|
|
604
|
+
signaller.create.emit()
|
|
605
|
+
|
|
606
|
+
return _waiter
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _create_tray(icon_path: str, window, port: int):
|
|
610
|
+
"""Create a QSystemTrayIcon (executed on the Qt main loop).
|
|
611
|
+
|
|
612
|
+
Uses window.native (BrowserView/QMainWindow) for direct show/hide,
|
|
613
|
+
bypassing the pywebview decorators.
|
|
614
|
+
"""
|
|
615
|
+
|
|
616
|
+
from PyQt6.QtCore import QCoreApplication
|
|
617
|
+
from PyQt6.QtGui import QAction, QIcon
|
|
618
|
+
from PyQt6.QtWidgets import QMenu, QSystemTrayIcon
|
|
619
|
+
|
|
620
|
+
app = QCoreApplication.instance()
|
|
621
|
+
if app is None:
|
|
622
|
+
print("⚠ Tray: no QApplication", flush=True)
|
|
623
|
+
return
|
|
624
|
+
|
|
625
|
+
if not QSystemTrayIcon.isSystemTrayAvailable():
|
|
626
|
+
print("⚠ Tray: not available on this desktop", flush=True)
|
|
627
|
+
return
|
|
628
|
+
|
|
629
|
+
def _show_window():
|
|
630
|
+
w = window.native
|
|
631
|
+
if w is None:
|
|
632
|
+
return
|
|
633
|
+
w.show()
|
|
634
|
+
w.raise_()
|
|
635
|
+
w.activateWindow()
|
|
636
|
+
|
|
637
|
+
def _hide_window():
|
|
638
|
+
w = window.native
|
|
639
|
+
if w is None:
|
|
640
|
+
return
|
|
641
|
+
w.hide()
|
|
642
|
+
|
|
643
|
+
icon = QIcon(icon_path) if os.path.exists(icon_path) else QIcon()
|
|
644
|
+
tray = QSystemTrayIcon(icon)
|
|
645
|
+
tray.setToolTip("OpenVidia")
|
|
646
|
+
|
|
647
|
+
menu = QMenu()
|
|
648
|
+
|
|
649
|
+
show_action = QAction("Show")
|
|
650
|
+
show_action.triggered.connect(_show_window)
|
|
651
|
+
menu.addAction(show_action)
|
|
652
|
+
|
|
653
|
+
menu.addSeparator()
|
|
654
|
+
|
|
655
|
+
quit_action = QAction("Quit")
|
|
656
|
+
quit_action.triggered.connect(lambda: (_kill_proxy_by_port(port), app.quit()))
|
|
657
|
+
menu.addAction(quit_action)
|
|
658
|
+
|
|
659
|
+
tray.setContextMenu(menu)
|
|
660
|
+
tray.show()
|
|
661
|
+
print("● Tray icon active", flush=True)
|
|
662
|
+
|
|
663
|
+
def _on_activated(reason):
|
|
664
|
+
if reason in (
|
|
665
|
+
QSystemTrayIcon.ActivationReason.Trigger,
|
|
666
|
+
QSystemTrayIcon.ActivationReason.DoubleClick,
|
|
667
|
+
):
|
|
668
|
+
_show_window()
|
|
669
|
+
|
|
670
|
+
tray.activated.connect(_on_activated)
|
|
671
|
+
|
|
672
|
+
# Global reference to prevent garbage collection of tray/menu/actions
|
|
673
|
+
global _tray_ref, _tray_hide
|
|
674
|
+
_tray_ref = (tray, menu, show_action, quit_action, _show_window, _on_activated)
|
|
675
|
+
_tray_hide = _hide_window
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def open_desk(port: int) -> None:
|
|
679
|
+
"""Open the dashboard in a native pywebview window with a system tray."""
|
|
680
|
+
try:
|
|
681
|
+
import webview
|
|
682
|
+
except ImportError:
|
|
683
|
+
print("⚠ pywebview not installed — opening in browser", flush=True)
|
|
684
|
+
from .webui import auto_open
|
|
685
|
+
|
|
686
|
+
auto_open(port)
|
|
687
|
+
return
|
|
688
|
+
|
|
689
|
+
# The token rides in the URL, the way Jupyter does it. It cannot be
|
|
690
|
+
# embedded in the page instead: / is unauthenticated (it has to be, or the
|
|
691
|
+
# dashboard could never bootstrap), so anything the HTML carries is
|
|
692
|
+
# readable by whoever can reach the port. main.js moves it straight into
|
|
693
|
+
# sessionStorage and strips it from the address bar.
|
|
694
|
+
url = f"http://localhost:{port}/?token={config.control_token()}"
|
|
695
|
+
assets = Path(__file__).resolve().parent / "web" / "assets"
|
|
696
|
+
icon_path = str(assets / "logo.png")
|
|
697
|
+
print(f"● Desktop window → {url}", flush=True)
|
|
698
|
+
|
|
699
|
+
window = webview.create_window(
|
|
700
|
+
"OpenVidia",
|
|
701
|
+
url=url,
|
|
702
|
+
width=310,
|
|
703
|
+
height=570,
|
|
704
|
+
min_size=(260, 300),
|
|
705
|
+
text_select=True,
|
|
706
|
+
easy_drag=True,
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
signaller = _make_signaller(icon_path, window, port)
|
|
710
|
+
# The tray is a QSystemTrayIcon and drives window.native as a Qt window,
|
|
711
|
+
# so the window must run on pywebview's Qt backend too. pywebview picks
|
|
712
|
+
# GTK first on any Linux desktop that is not KDE (Hyprland/Omarchy, GNOME,
|
|
713
|
+
# Sway): the Qt application never existed, the tray never appeared, and
|
|
714
|
+
# close-to-tray hid the window with nothing left to bring it back.
|
|
715
|
+
gui = "qt" if sys.platform.startswith("linux") else None
|
|
716
|
+
|
|
717
|
+
def on_closing():
|
|
718
|
+
"""Close-to-tray: hide the window, do NOT kill the proxy."""
|
|
719
|
+
try:
|
|
720
|
+
h = _tray_hide
|
|
721
|
+
if h is not None:
|
|
722
|
+
h()
|
|
723
|
+
else:
|
|
724
|
+
window.hide()
|
|
725
|
+
except Exception:
|
|
726
|
+
pass
|
|
727
|
+
return False
|
|
728
|
+
|
|
729
|
+
window.events.closing += on_closing
|
|
730
|
+
# NOTE: no killer on `closed` here. With a tray icon the window is a view
|
|
731
|
+
# onto a background service — closing it hides the view, and the proxy
|
|
732
|
+
# must keep serving the CLIs. Only the tray's Quit stops it. (This line
|
|
733
|
+
# used to kill the proxy and appeared harmless purely because the kill was
|
|
734
|
+
# SIGTERM-only and uvicorn ignored it while streams were open; once the
|
|
735
|
+
# kill was made to actually work, closing the window took the proxy down
|
|
736
|
+
# with it.) The no-tray fallback below registers its own handler, because
|
|
737
|
+
# without a tray there is nothing left to stop it from.
|
|
738
|
+
|
|
739
|
+
if signaller is not None:
|
|
740
|
+
webview.start(
|
|
741
|
+
func=_tray_waiter_factory(signaller, window),
|
|
742
|
+
gui=gui,
|
|
743
|
+
debug=False,
|
|
744
|
+
icon=icon_path,
|
|
745
|
+
)
|
|
746
|
+
else:
|
|
747
|
+
# Fallback: no tray, close = kill proxy
|
|
748
|
+
def kill_proxy():
|
|
749
|
+
_kill_proxy_by_port(port)
|
|
750
|
+
print("● Desk closed — proxy terminated", flush=True)
|
|
751
|
+
|
|
752
|
+
window.events.closing -= on_closing
|
|
753
|
+
window.events.closed += kill_proxy
|
|
754
|
+
webview.start(gui=gui, debug=False, icon=icon_path)
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
USAGE = f"""OpenVidia — multi-key proxy for NVIDIA NIM
|
|
758
|
+
|
|
759
|
+
openvidia start the proxy and open the dashboard
|
|
760
|
+
openvidia foreground start the proxy in this terminal (logs to stdout)
|
|
761
|
+
openvidia setup configure opencode + Jcode to use the proxy (backs up first)
|
|
762
|
+
openvidia run <cli> [args] run another CLI against the proxy, changing no files
|
|
763
|
+
openvidia --help this message
|
|
764
|
+
|
|
765
|
+
Dashboard {ROOT_URL}
|
|
766
|
+
API base {BASE_URL}
|
|
767
|
+
Keys {{keys_path}}
|
|
768
|
+
|
|
769
|
+
`run` sets the CLI's base-URL variables for that one process only. Override the
|
|
770
|
+
variables per CLI in {{targets_path}}."""
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _print_usage() -> None:
|
|
774
|
+
print(
|
|
775
|
+
USAGE.format(
|
|
776
|
+
keys_path=config.config_path(),
|
|
777
|
+
targets_path=config.config_dir() / "cli_targets.json",
|
|
778
|
+
)
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def main():
|
|
783
|
+
"""CLI entrypoint: dispatch on argv[1]."""
|
|
784
|
+
if len(sys.argv) > 1:
|
|
785
|
+
cmd = sys.argv[1]
|
|
786
|
+
if cmd in ("-h", "--help", "help"):
|
|
787
|
+
_print_usage()
|
|
788
|
+
return
|
|
789
|
+
if cmd == "setup":
|
|
790
|
+
_setup_cmd()
|
|
791
|
+
return
|
|
792
|
+
if cmd == "run":
|
|
793
|
+
sys.exit(_run_cmd(sys.argv[2:]) or 0)
|
|
794
|
+
if cmd == "foreground":
|
|
795
|
+
asyncio.run(main_async())
|
|
796
|
+
return
|
|
797
|
+
# Unknown arguments used to be ignored silently, which made a typo
|
|
798
|
+
# look like a successful launch.
|
|
799
|
+
print(f"✗ unknown command: {cmd}\n")
|
|
800
|
+
_print_usage()
|
|
801
|
+
sys.exit(2)
|
|
802
|
+
|
|
803
|
+
import subprocess as _sp
|
|
804
|
+
|
|
805
|
+
if not _kill_stale_port(PORT):
|
|
806
|
+
print(f" Free it manually: fuser -k {PORT}/tcp", flush=True)
|
|
807
|
+
sys.exit(1)
|
|
808
|
+
|
|
809
|
+
child = _sp.Popen(
|
|
810
|
+
[sys.executable, "-m", "openvidia", "foreground"],
|
|
811
|
+
stdout=_sp.DEVNULL,
|
|
812
|
+
stderr=_sp.DEVNULL,
|
|
813
|
+
stdin=_sp.DEVNULL,
|
|
814
|
+
)
|
|
815
|
+
|
|
816
|
+
# Wait for the server to actually answer before opening the window.
|
|
817
|
+
# A fixed sleep(3) hid every startup failure: the window opened onto a
|
|
818
|
+
# dead port, or onto a survivor still running the previous build.
|
|
819
|
+
if not _wait_until_serving(PORT, child, timeout=20.0):
|
|
820
|
+
sys.exit(1)
|
|
821
|
+
|
|
822
|
+
# Desk app — compact native window
|
|
823
|
+
open_desk(PORT)
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
if __name__ == "__main__":
|
|
827
|
+
main()
|