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/safe_file.py ADDED
@@ -0,0 +1,100 @@
1
+ """Safe file operations with automatic backups."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+
10
+
11
+ def create_backup(
12
+ file_path: Path,
13
+ backup_dir: Path | None = None,
14
+ max_backups: int = 5,
15
+ ) -> Path | None:
16
+ """Create a timestamped backup of a file.
17
+
18
+ Args:
19
+ file_path: Path to the file to backup
20
+ backup_dir: Directory for backups (default: same dir as file)
21
+ max_backups: Maximum number of backups to keep
22
+
23
+ Returns:
24
+ Path to the backup file, or None if backup failed
25
+ """
26
+ if not file_path.exists():
27
+ return None
28
+
29
+ backup_dir = backup_dir or file_path.parent
30
+ backup_dir.mkdir(parents=True, exist_ok=True)
31
+
32
+ # Microseconds, not seconds. At second granularity two backups taken in the
33
+ # same second resolved to the same filename, so the second silently
34
+ # overwrote the first — the rotation kept one file where it claimed five,
35
+ # and the test that was supposed to catch it passed for the same reason.
36
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
37
+ backup_name = f"{file_path.stem}_backup_{timestamp}{file_path.suffix}"
38
+ backup_path = backup_dir / backup_name
39
+
40
+ try:
41
+ shutil.copy2(file_path, backup_path)
42
+ # copy2 preserves the source mode, which is right for a key file — but
43
+ # a backup taken from a file an older version wrote 0644 would inherit
44
+ # 0644. Pin it: a backup of a secret is still a secret.
45
+ if os.name != "nt":
46
+ os.chmod(backup_path, 0o600)
47
+
48
+ # Cleanup old backups
49
+ cleanup_old_backups(file_path, backup_dir, max_backups)
50
+
51
+ return backup_path
52
+ except OSError as e:
53
+ print(f"Warning: Could not create backup: {e}")
54
+ return None
55
+
56
+
57
+ def cleanup_old_backups(
58
+ original_file: Path,
59
+ backup_dir: Path | None = None,
60
+ max_backups: int = 5,
61
+ ) -> int:
62
+ """Remove old backups, keeping only the most recent ones.
63
+
64
+ Returns:
65
+ Number of backups removed
66
+ """
67
+ backup_dir = backup_dir or original_file.parent
68
+
69
+ # Find all backups for this file
70
+ prefix = f"{original_file.stem}_backup_"
71
+ suffix = original_file.suffix
72
+
73
+ backups = []
74
+ for f in backup_dir.iterdir():
75
+ if f.is_file() and f.name.startswith(prefix) and f.name.endswith(suffix):
76
+ backups.append(f)
77
+
78
+ # Sort by modification time (newest first)
79
+ backups.sort(key=lambda p: p.stat().st_mtime, reverse=True)
80
+
81
+ # Remove old backups
82
+ removed = 0
83
+ for old_backup in backups[max_backups:]:
84
+ try:
85
+ old_backup.unlink()
86
+ removed += 1
87
+ except OSError:
88
+ pass
89
+
90
+ return removed
91
+
92
+
93
+ # Removed with this commit: safe_write_with_backup, restore_from_backup and
94
+ # list_backups. None had a production caller — the test suite was their only
95
+ # consumer, which made a dead API look maintained. safe_write_with_backup also
96
+ # duplicated config.atomic_write with a *different* temp-file convention
97
+ # (keys.json.tmp vs keys.tmp) and without the 0600 mode or the fsync, so the
98
+ # two would have raced each other had anything used both. restore_from_backup
99
+ # built its path from an unvalidated timestamp, so a caller that ever exposed
100
+ # it over HTTP would have handed out arbitrary file overwrite.
@@ -0,0 +1,106 @@
1
+ """
2
+ Owns the actual uvicorn server instance(s) for one proxy run.
3
+
4
+ Binds loopback on BOTH IP stacks so a client reaching us as "localhost"
5
+ works whether its resolver picks IPv4 (127.0.0.1) or IPv6 (::1) — the
6
+ latter is what Node/Bun try first. Loopback-only, never all-interfaces:
7
+ the proxy serves unauthenticated API keys. IPv6 loopback is best-effort —
8
+ don't fail proxy startup on hosts with IPv6 off.
9
+ """
10
+
11
+ import asyncio
12
+ import socket as socket_mod
13
+ from collections.abc import Callable
14
+ from pathlib import Path
15
+
16
+ import uvicorn
17
+
18
+ from .proxy_app import create_app
19
+ from .proxy_state import ProxyState, ProxyStats
20
+
21
+
22
+ class ProxyServer:
23
+ def __init__(
24
+ self,
25
+ servers: list[uvicorn.Server],
26
+ tasks: list[asyncio.Task],
27
+ state: ProxyState,
28
+ ):
29
+ self._servers = servers
30
+ self._tasks = tasks
31
+ self.state = state
32
+
33
+ async def shutdown(self):
34
+ for s in self._servers:
35
+ s.should_exit = True
36
+ for t in self._tasks:
37
+ try:
38
+ await asyncio.wait_for(t, timeout=2.0)
39
+ except (TimeoutError, asyncio.CancelledError):
40
+ t.cancel()
41
+
42
+
43
+ def _bind(family: int, host: str, port: int) -> socket_mod.socket | None:
44
+ try:
45
+ sock = socket_mod.socket(family, socket_mod.SOCK_STREAM)
46
+ except OSError:
47
+ return None
48
+ sock.setsockopt(socket_mod.SOL_SOCKET, socket_mod.SO_REUSEADDR, 1)
49
+ try:
50
+ sock.bind((host, port))
51
+ sock.listen(128)
52
+ sock.setblocking(False)
53
+ return sock
54
+ except OSError:
55
+ sock.close()
56
+ return None
57
+
58
+
59
+ async def start(
60
+ port: int,
61
+ keys: list[str],
62
+ log_cb: Callable[[str], None],
63
+ stats: ProxyStats,
64
+ web_dir: Path | None = None,
65
+ initial_model: str = "",
66
+ ) -> ProxyServer:
67
+ state = ProxyState(keys=keys, stats=stats, log_cb=log_cb, port=port)
68
+ if initial_model:
69
+ state.active_model = initial_model
70
+ app = create_app(state, web_dir=web_dir)
71
+
72
+ # Bind explicitly first (like Rust's TcpListener::bind) so a host with
73
+ # IPv6 off just skips that stack instead of taking the whole proxy down.
74
+ v4_sock = _bind(socket_mod.AF_INET, "127.0.0.1", port)
75
+ if v4_sock is None:
76
+ raise RuntimeError(f"proxy failed to bind :{port} (IPv4 loopback)")
77
+
78
+ v6_sock = _bind(socket_mod.AF_INET6, "::1", port)
79
+
80
+ servers: list[uvicorn.Server] = []
81
+ tasks: list[asyncio.Task] = []
82
+ socks = [v4_sock]
83
+ if v6_sock is not None:
84
+ socks.append(v6_sock)
85
+
86
+ v4_config = uvicorn.Config(app, log_level="warning", lifespan="on")
87
+ v4_server = uvicorn.Server(v4_config)
88
+ servers.append(v4_server)
89
+ tasks.append(asyncio.create_task(v4_server.serve(sockets=[v4_sock])))
90
+
91
+ if v6_sock is not None:
92
+ v6_config = uvicorn.Config(app, log_level="warning", lifespan="off")
93
+ v6_server = uvicorn.Server(v6_config)
94
+ servers.append(v6_server)
95
+ tasks.append(asyncio.create_task(v6_server.serve(sockets=[v6_sock])))
96
+
97
+ for _ in range(100):
98
+ if v4_server.started:
99
+ break
100
+ await asyncio.sleep(0.02)
101
+ else:
102
+ for s in socks:
103
+ s.close()
104
+ raise RuntimeError(f"proxy failed to start on :{port}")
105
+
106
+ return ProxyServer(servers, tasks, state)
@@ -0,0 +1,50 @@
1
+ """Round-robin router over multiple NVIDIA-compatible upstream endpoints.
2
+
3
+ A single integrate.api.nvidia.com can 429 the whole account: keeping a
4
+ secondary endpoint (same key family, different host) and blacklisting a host
5
+ for a short window after a 5xx/timeout spreads load and survives partial
6
+ outages.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import time
12
+
13
+ ENDPOINT_RETRY_AFTER = 60.0
14
+
15
+
16
+ class EndpointRouter:
17
+ """Tracks per-endpoint health and hands out the healthy ones in order."""
18
+
19
+ def __init__(self, endpoints: list[str]) -> None:
20
+ self.endpoints = [e.rstrip("/") + "/" for e in endpoints]
21
+ self._blacklisted_until: dict[str, float] = {}
22
+
23
+ def healthy_endpoints(self) -> list[str]:
24
+ now = time.monotonic()
25
+ return [e for e in self.endpoints if self._blacklisted_until.get(e, 0.0) <= now]
26
+
27
+ def mark_failure(self, url: str) -> None:
28
+ for endpoint in self.endpoints:
29
+ if url.startswith(endpoint):
30
+ self._blacklisted_until[endpoint] = time.monotonic() + ENDPOINT_RETRY_AFTER
31
+ return
32
+
33
+ def mark_success(self, url: str) -> None:
34
+ for endpoint in self.endpoints:
35
+ if url.startswith(endpoint):
36
+ self._blacklisted_until.pop(endpoint, None)
37
+ return
38
+
39
+ def as_dict(self) -> dict[str, object]:
40
+ now = time.monotonic()
41
+ return {
42
+ "endpoints": [
43
+ {
44
+ "url": e,
45
+ "healthy": self._blacklisted_until.get(e, 0.0) <= now,
46
+ "retry_in_s": max(0.0, self._blacklisted_until.get(e, 0.0) - now),
47
+ }
48
+ for e in self.endpoints
49
+ ]
50
+ }
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,166 @@
1
+ <!doctype html>
2
+ <html lang="en" data-theme="dark">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
6
+ <meta name="description" content="OpenVidia — local multi-key proxy control panel for NVIDIA-hosted models." />
7
+ <title>OpenVidia</title>
8
+ <link rel="icon" type="image/x-icon" href="favicon.ico">
9
+ <link rel="stylesheet" href="styles.css" />
10
+ </head>
11
+ <body>
12
+ <div id="bg-canvas"></div>
13
+ <div id="toast-container"></div>
14
+
15
+ <div id="app">
16
+ <header>
17
+ <div class="logo">
18
+ <img src="logo.png" alt="OpenVidia" class="logo-img">
19
+ <span class="logo-text">OpenVidia</span>
20
+ </div>
21
+ <div class="header-right">
22
+ <span class="header-stat upstream-badge" id="hUpstream" title="NVIDIA upstream health — probed every 30s">
23
+ <span class="up-dot" id="upDot"></span> NVIDIA <span id="upText">…</span>
24
+ </span>
25
+ <span class="header-stat" id="hKeyCount">0 keys</span>
26
+ <span class="header-stat" id="hRpm">0 RPM</span>
27
+ <button class="theme-btn" id="themeToggle" title="Toggle theme">🌙</button>
28
+ </div>
29
+ </header>
30
+
31
+ <div class="bento">
32
+ <!-- ── STATUS (cell-status) ── -->
33
+ <div class="bento-cell cell-status">
34
+ <span class="indicator-dot" id="statusDot"><span class="pulse-dot running"></span></span>
35
+ <div class="status-info">
36
+ <span class="status-label" id="statusText">Running</span>
37
+ <span class="status-meta">Port <strong id="portDisplay">1919</strong> · <strong id="activeModelDisplay">none</strong></span>
38
+ <!-- Reasoning toggle: a hybrid model emits nothing until it stops
39
+ thinking, which is the difference between a 2s and a 160s
40
+ first token. Applies to the active model. -->
41
+ <span class="status-meta think-row">
42
+ Thinking
43
+ <button class="btn btn-xs think-btn" data-think="auto" title="Leave it to the model">auto</button>
44
+ <button class="btn btn-xs think-btn" data-think="on" title="Force reasoning on">on</button>
45
+ <button class="btn btn-xs think-btn" data-think="off" title="Force reasoning off — much faster first token">off</button>
46
+ </span>
47
+ <!-- Reasoning effort: finer granularity than on/off.
48
+ low = fast/zero thinking, medium = balanced, high = deep/focused -->
49
+ <span class="status-meta effort-row">
50
+ Effort
51
+ <button class="btn btn-xs effort-btn" data-effort="auto" title="Defer to thinking toggle">auto</button>
52
+ <button class="btn btn-xs effort-btn" data-effort="low" title="Fast — no reasoning, temp 0.7">low</button>
53
+ <button class="btn btn-xs effort-btn" data-effort="medium" title="Balanced — thinking on, temp 0.5">med</button>
54
+ <button class="btn btn-xs effort-btn" data-effort="high" title="Deep — thinking on, temp 0.2">high</button>
55
+ </span>
56
+ <span class="status-meta fallback-row">
57
+ Fallback
58
+ <button class="btn btn-xs fallback-btn" data-fallback="off" title="Never failover — return 503 if model is down">off</button>
59
+ <button class="btn btn-xs fallback-btn" data-fallback="on" title="Always failover to next healthy preset">on</button>
60
+ <button class="btn btn-xs fallback-btn" data-fallback="auto" title="Failover only when circuit is open">auto</button>
61
+ </span>
62
+ </div>
63
+ <div class="status-acts">
64
+ <button class="btn btn-sm" id="stopBtn" title="Stop">⏹</button>
65
+ <button class="btn btn-sm" id="startBtn" title="Start" style="display:none">▶</button>
66
+ <button class="btn btn-sm" id="restartBtn" title="Restart">⟳</button>
67
+ </div>
68
+ </div>
69
+
70
+ <!-- ── STATS (cell-stats, col-span-3) ── -->
71
+ <div class="bento-cell cell-stats col-span-3">
72
+ <div class="stat-mini"><span class="stat-num" id="statReqs">0</span><span class="stat-label">req</span></div>
73
+ <div class="stat-mini"><span class="stat-num" id="statOk">0</span><span class="stat-label">ok</span></div>
74
+ <div class="stat-mini"><span class="stat-num" id="statRots">0</span><span class="stat-label">rot</span></div>
75
+ <div class="stat-mini" id="cdMini"><span class="stat-num" id="statCooldowns">0</span><span class="stat-label">⏳</span></div>
76
+ </div>
77
+
78
+ <!-- ── MODELS (single list; ★ Starred = quick-pick shortlist) ── -->
79
+ <div class="bento-cell cell-models col-span-2" data-dropdown>
80
+ <div class="bento-hd" data-toggle data-idx="01">
81
+ <span class="bento-ttl">Models</span>
82
+ <span class="bento-badge" id="modelCount">0</span>
83
+ <span class="chevron">▸</span>
84
+ </div>
85
+ <div class="bento-body" data-body>
86
+ <div class="bento-body-inner">
87
+ <div class="browser-filters" id="modelFilters"></div>
88
+ <input type="text" class="browser-input" id="modelSearch" placeholder="Search models…" spellcheck="false" />
89
+ <div class="browser-list" id="modelList"><div class="browser-empty">Loading…</div></div>
90
+ </div>
91
+ </div>
92
+ </div>
93
+
94
+ <!-- ── KEY MANAGEMENT (right after status/stats, full width, open by default) ── -->
95
+ <div class="bento-cell cell-keys col-span-4 open" data-dropdown>
96
+ <div class="bento-hd" data-toggle data-idx="02">
97
+ <span class="bento-ttl">Keys</span>
98
+ <span class="bento-badge" id="keyCount">0</span>
99
+ <span class="chevron">▸</span>
100
+ </div>
101
+ <div class="bento-body" data-body>
102
+ <div class="bento-body-inner">
103
+ <div class="keys-toolbar">
104
+ <div class="key-filters" id="keyFilters">
105
+ <button class="key-filter" data-filter="all">All <span class="filter-count" id="fAll">0</span></button>
106
+ <button class="key-filter active" data-filter="active">Active <span class="filter-count" id="fActive">0</span></button>
107
+ <button class="key-filter" data-filter="cooldown">⏳ <span class="filter-count" id="fCooldown">0</span></button>
108
+ <button class="key-filter" data-filter="fresh">● <span class="filter-count" id="fFresh">0</span></button>
109
+ <button class="key-filter" data-filter="idle">Idle <span class="filter-count" id="fIdle">0</span></button>
110
+ </div>
111
+ <button class="btn btn-sm btn-primary" id="addKeyBtn">+ Add Key</button>
112
+ </div>
113
+ <div class="slide-form hidden" id="keyAddForm">
114
+ <input type="text" id="newKeyInput" spellcheck="false" placeholder="nvapi-…" autocomplete="off" />
115
+ <div class="slide-form-actions">
116
+ <button class="btn btn-sm btn-primary" id="confirmAddKeyBtn">Add</button>
117
+ <button class="btn btn-sm" id="cancelAddKeyBtn">Cancel</button>
118
+ </div>
119
+ </div>
120
+ <div class="empty-state hidden" id="keysEmpty">
121
+ <div class="empty-ttl">No keys loaded</div>
122
+ <div class="empty-desc">Add keys with the button above</div>
123
+ </div>
124
+ <div id="keysList" class="keys-list"></div>
125
+ </div>
126
+ </div>
127
+ </div>
128
+
129
+ <!-- ── ACTIVITY LOG (cell-log, col-span-2) ── -->
130
+ <div class="bento-cell cell-log col-span-2" data-dropdown>
131
+ <div class="bento-hd" data-toggle data-idx="04">
132
+ <span class="bento-ttl"><span class="log-dot" id="logDot"></span>Activity</span>
133
+ <span class="bento-badge" id="logCount">0</span>
134
+ <span class="chevron">▸</span>
135
+ </div>
136
+ <div class="bento-body" data-body>
137
+ <div class="bento-body-inner">
138
+ <div class="log-toolbar">
139
+ <button class="btn btn-sm" id="clearLogBtn">Clear</button>
140
+ </div>
141
+ <div class="log-box" id="logArea">
142
+ <div class="log-empty">Waiting for events…</div>
143
+ </div>
144
+ </div>
145
+ </div>
146
+ </div>
147
+
148
+ <!-- ── CLI SETUP (cell-cli, full width) ── -->
149
+ <div class="bento-cell cell-cli col-span-4" data-dropdown>
150
+ <div class="bento-hd" data-toggle data-idx="05">
151
+ <span class="bento-ttl">CLI Setup</span>
152
+ <span class="chevron">▸</span>
153
+ </div>
154
+ <div class="bento-body" data-body>
155
+ <div class="bento-body-inner">
156
+ <div class="cli-tabs" id="cliTabs"></div>
157
+ <div class="cli-setup" id="cliSetup"></div>
158
+ </div>
159
+ </div>
160
+ </div>
161
+ </div>
162
+ </div>
163
+
164
+ <script type="module" src="main.js"></script>
165
+ </body>
166
+ </html>
openvidia/web/logo.png ADDED
Binary file