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/__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()