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/config.py
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
"""Cross-platform config paths and atomic file helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import secrets
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def config_dir() -> Path:
|
|
16
|
+
"""Platform-specific config directory, private to the user.
|
|
17
|
+
|
|
18
|
+
The directory holds API keys in cleartext, so it is chmod'd 0700 on every
|
|
19
|
+
call — cheap, and it repairs a directory created by an older version that
|
|
20
|
+
left it world-readable.
|
|
21
|
+
"""
|
|
22
|
+
if sys.platform == "win32":
|
|
23
|
+
d = Path(os.environ.get("APPDATA", Path.home())) / "openvidia"
|
|
24
|
+
elif sys.platform == "darwin":
|
|
25
|
+
d = Path.home() / "Library" / "Application Support" / "openvidia"
|
|
26
|
+
else:
|
|
27
|
+
xdg = os.environ.get("XDG_CONFIG_HOME", "")
|
|
28
|
+
d = Path(xdg) / "openvidia" if xdg else Path.home() / ".config" / "openvidia"
|
|
29
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
if sys.platform != "win32":
|
|
31
|
+
try:
|
|
32
|
+
d.chmod(0o700)
|
|
33
|
+
except OSError:
|
|
34
|
+
pass
|
|
35
|
+
return d
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ── Upstream timeouts ──────────────────────────────────────────────────
|
|
39
|
+
# `read` is the wait for the FIRST byte of a streamed answer, and a
|
|
40
|
+
# reasoning model emits nothing at all while it thinks. Measured on the
|
|
41
|
+
# NVIDIA free tier: z-ai/glm-5.2 takes ~117s to first byte on a 2k-token
|
|
42
|
+
# prompt and ~143s on a 20k one — latency driven by the model, not by the
|
|
43
|
+
# prompt size. The previous 30s ceiling therefore made every request to a
|
|
44
|
+
# slow model time out on every key in the pool, and the proxy blamed the
|
|
45
|
+
# keys for it. Single source of truth: all three request paths (chat
|
|
46
|
+
# completions, /v1/responses, /v1/messages) import this.
|
|
47
|
+
_TIMEOUT_DEFAULTS = {
|
|
48
|
+
"connect": 5.0,
|
|
49
|
+
"read": 180.0,
|
|
50
|
+
"write": 30.0,
|
|
51
|
+
"pool": 240.0,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
_HTTPX_TIMEOUT_KEYS = ("connect", "read", "write", "pool")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def httpx_timeout_kwargs() -> dict[str, float]:
|
|
58
|
+
"""Return configured upstream timeouts as kwargs for `httpx.Timeout`."""
|
|
59
|
+
out = dict(_TIMEOUT_DEFAULTS)
|
|
60
|
+
try:
|
|
61
|
+
p = config_dir() / "timeouts.json"
|
|
62
|
+
if p.exists():
|
|
63
|
+
user = json.loads(p.read_text())
|
|
64
|
+
if isinstance(user, dict):
|
|
65
|
+
for k in _HTTPX_TIMEOUT_KEYS:
|
|
66
|
+
if k in user and isinstance(user[k], int | float):
|
|
67
|
+
out[k] = float(user[k])
|
|
68
|
+
except (json.JSONDecodeError, OSError):
|
|
69
|
+
pass
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ── Upstream endpoints (single source of truth) ────────────────────────
|
|
74
|
+
# NVIDIA NIM base URL; the /chat/completions variant is used by
|
|
75
|
+
# responses_shim, anthropic_shim and compaction.
|
|
76
|
+
UPSTREAM_BASE = "https://integrate.api.nvidia.com/v1/"
|
|
77
|
+
UPSTREAM_CHAT = UPSTREAM_BASE + "chat/completions"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def upstream_http2() -> bool:
|
|
81
|
+
"""Whether upstream requests share one multiplexed HTTP/2 connection.
|
|
82
|
+
|
|
83
|
+
With HTTP/2 a single GOAWAY from the upstream (``ConnectionTerminated``)
|
|
84
|
+
kills every in-flight stream at once, and a client mid-stream sees a
|
|
85
|
+
broken event. ``OPENVIDIA_HTTP2=0`` gives each request its own HTTP/1.1
|
|
86
|
+
connection, so a closed connection costs one stream, not all of them.
|
|
87
|
+
"""
|
|
88
|
+
return os.environ.get("OPENVIDIA_HTTP2", "1").strip().lower() not in ("0", "false", "no", "off")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def outbound_proxy() -> str | None:
|
|
92
|
+
"""Return outbound HTTP/SOCKS5 proxy URL for upstream requests.
|
|
93
|
+
|
|
94
|
+
Overridable via OPENVIDIA_OUTBOUND_PROXY or proxy_config.json.
|
|
95
|
+
"""
|
|
96
|
+
env_proxy = (
|
|
97
|
+
os.environ.get("OPENVIDIA_OUTBOUND_PROXY")
|
|
98
|
+
or os.environ.get("HTTPS_PROXY")
|
|
99
|
+
or os.environ.get("HTTP_PROXY")
|
|
100
|
+
or os.environ.get("https_proxy")
|
|
101
|
+
or os.environ.get("http_proxy")
|
|
102
|
+
)
|
|
103
|
+
if env_proxy:
|
|
104
|
+
return env_proxy
|
|
105
|
+
try:
|
|
106
|
+
p = config_dir() / "proxy_config.json"
|
|
107
|
+
if p.exists():
|
|
108
|
+
data = json.loads(p.read_text())
|
|
109
|
+
if isinstance(data, dict) and data.get("outbound_proxy"):
|
|
110
|
+
return str(data["outbound_proxy"])
|
|
111
|
+
except (json.JSONDecodeError, OSError):
|
|
112
|
+
pass
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ── Thinking / reasoning toggle ────────────────────────────────────────
|
|
117
|
+
# Hybrid reasoning models emit nothing until they finish thinking, which is
|
|
118
|
+
# the difference between a 2s and a 160s first token. Providers expose the
|
|
119
|
+
# switch under different names and it changes with every model generation, so
|
|
120
|
+
# the PAYLOAD is configuration, not code: when the next model uses a
|
|
121
|
+
# different flag, edit model_options.json instead of shipping a release.
|
|
122
|
+
_MODEL_OPTIONS_DEFAULTS = {
|
|
123
|
+
"thinking": "auto", # "auto" (send nothing) | "on" | "off"
|
|
124
|
+
# NVIDIA NIM 2026: new models use enable_thinking, older use chat_template_kwargs.thinking
|
|
125
|
+
"thinking_off_payload": {"chat_template_kwargs": {"enable_thinking": False}},
|
|
126
|
+
"thinking_on_payload": {"chat_template_kwargs": {"enable_thinking": True}},
|
|
127
|
+
# ── Reasoning effort: finer granularity than on/off ──
|
|
128
|
+
# low → thinking off + high temperature (fast, zero reasoning)
|
|
129
|
+
# medium → thinking on + medium temperature (balanced)
|
|
130
|
+
# high → thinking on + low temperature (focused, deep reasoning)
|
|
131
|
+
"reasoning_effort": "auto", # "auto" | "low" | "medium" | "high"
|
|
132
|
+
"effort_payloads": {
|
|
133
|
+
"low": {"chat_template_kwargs": {"enable_thinking": False}, "temperature": 0.7},
|
|
134
|
+
"medium": {"chat_template_kwargs": {"enable_thinking": True}, "temperature": 0.5},
|
|
135
|
+
"high": {"chat_template_kwargs": {"enable_thinking": True}, "temperature": 0.2},
|
|
136
|
+
},
|
|
137
|
+
# ── Fallback / failover control ───────────────────────────────────────
|
|
138
|
+
# "auto" = failover when circuit is open (current behavior)
|
|
139
|
+
# "off" = never failover, return 503 if model circuit is open
|
|
140
|
+
# "on" = always failover to next healthy preset (future use)
|
|
141
|
+
"fallback": "off",
|
|
142
|
+
# ── Per-model hardcoded optimizations ─────────────────────────────────
|
|
143
|
+
# These are defaults from NVIDIA docs — the dashboard can still override.
|
|
144
|
+
# Only models the provider still serves belong here: an entry for a
|
|
145
|
+
# retired id is dead weight that hides which tuning is current. The
|
|
146
|
+
# catalog workflow (.github/workflows/catalog.yml) fails when one goes.
|
|
147
|
+
# Key insight:
|
|
148
|
+
# Nemotron Ultra: enable_thinking=False mandatory for tool calling;
|
|
149
|
+
# temperature=1.0, top_p=0.95 per NVIDIA best practice
|
|
150
|
+
# DeepSeek V4.1 Flash: temperature=1.0, top_p=0.95 per its model card
|
|
151
|
+
"per_model": {
|
|
152
|
+
"deepseek-ai/deepseek-v4.1-flash": {
|
|
153
|
+
"extra_payload": {
|
|
154
|
+
"temperature": 1.0,
|
|
155
|
+
"top_p": 0.95,
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
"nvidia/nemotron-3-ultra-550b-a55b": {
|
|
159
|
+
"thinking": "off",
|
|
160
|
+
# temperature=1.0, top_p=0.95: NVIDIA recommended for Nemotron reasoning modes
|
|
161
|
+
# enable_thinking MUST be False for tool calling (otherwise hangs)
|
|
162
|
+
"extra_payload": {
|
|
163
|
+
"chat_template_kwargs": {"enable_thinking": False},
|
|
164
|
+
"temperature": 1.0,
|
|
165
|
+
"top_p": 0.95,
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
"poolside/laguna-xs-2.1": {
|
|
169
|
+
"thinking": "off",
|
|
170
|
+
"extra_payload": {
|
|
171
|
+
"chat_template_kwargs": {"enable_thinking": False},
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def model_options_path() -> Path:
|
|
179
|
+
return config_dir() / "model_options.json"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def model_options() -> dict:
|
|
183
|
+
opts = copy.deepcopy(_MODEL_OPTIONS_DEFAULTS)
|
|
184
|
+
try:
|
|
185
|
+
p = model_options_path()
|
|
186
|
+
if p.exists():
|
|
187
|
+
saved = json.loads(p.read_text())
|
|
188
|
+
if isinstance(saved, dict):
|
|
189
|
+
_fill_missing(saved, opts)
|
|
190
|
+
return saved
|
|
191
|
+
except (json.JSONDecodeError, OSError):
|
|
192
|
+
pass
|
|
193
|
+
return opts
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def save_model_options(opts: dict) -> None:
|
|
197
|
+
atomic_write(model_options_path(), json.dumps(opts, indent=2))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _fill_missing(dst: dict, src: dict) -> dict:
|
|
201
|
+
"""Recursively add keys from ``src`` that ``dst`` does not already have.
|
|
202
|
+
|
|
203
|
+
Fill, never overwrite: the dashboard sets a default, but a CLI that spells
|
|
204
|
+
the parameter out in its own request has made an explicit choice and must
|
|
205
|
+
win at every level of nesting.
|
|
206
|
+
"""
|
|
207
|
+
for k, v in src.items():
|
|
208
|
+
if isinstance(v, dict) and isinstance(dst.get(k), dict):
|
|
209
|
+
_fill_missing(dst[k], v)
|
|
210
|
+
elif k not in dst:
|
|
211
|
+
dst[k] = copy.deepcopy(v)
|
|
212
|
+
return dst
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def apply_model_options(payload: dict) -> dict:
|
|
216
|
+
"""Merge the configured thinking/reasoning payload into an outgoing chat request.
|
|
217
|
+
|
|
218
|
+
Priority (high → low):
|
|
219
|
+
1. per_model.extra_payload — model-specific optimal params (enable_thinking, temperature…)
|
|
220
|
+
2. reasoning_effort — low/medium/high slider
|
|
221
|
+
3. thinking on/off — simple binary toggle
|
|
222
|
+
Never overwrites something the client already set explicitly.
|
|
223
|
+
"""
|
|
224
|
+
if not isinstance(payload, dict):
|
|
225
|
+
return payload
|
|
226
|
+
opts = model_options()
|
|
227
|
+
model = payload.get("model") or ""
|
|
228
|
+
per = (opts.get("per_model") or {}).get(model, {})
|
|
229
|
+
|
|
230
|
+
# 1. Per-model extra_payload (model-specific optimal params like enable_thinking: False, temp, etc.)
|
|
231
|
+
extra_payload = per.get("extra_payload")
|
|
232
|
+
if isinstance(extra_payload, dict):
|
|
233
|
+
_fill_missing(payload, extra_payload)
|
|
234
|
+
|
|
235
|
+
# 2. Reasoning effort override (if specified for model or globally and not auto)
|
|
236
|
+
effort = per.get("reasoning_effort") or opts.get("reasoning_effort", "auto")
|
|
237
|
+
if effort != "auto":
|
|
238
|
+
extra = (opts.get("effort_payloads") or {}).get(effort)
|
|
239
|
+
if isinstance(extra, dict):
|
|
240
|
+
_fill_missing(payload, extra)
|
|
241
|
+
return payload
|
|
242
|
+
|
|
243
|
+
# 3. Fallback: binary auto/on/off thinking toggle
|
|
244
|
+
mode = per.get("thinking") or opts.get("thinking", "auto")
|
|
245
|
+
if mode == "off":
|
|
246
|
+
extra = opts.get("thinking_off_payload") or {}
|
|
247
|
+
if isinstance(extra, dict):
|
|
248
|
+
_fill_missing(payload, extra)
|
|
249
|
+
elif mode == "on":
|
|
250
|
+
extra = opts.get("thinking_on_payload") or {}
|
|
251
|
+
if isinstance(extra, dict):
|
|
252
|
+
_fill_missing(payload, extra)
|
|
253
|
+
return payload
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def config_path() -> Path:
|
|
257
|
+
return config_dir() / "keys.json"
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def lock_path() -> Path:
|
|
261
|
+
return config_dir() / "singleton.lock"
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# Model ids are vendor/name slugs. Anything else is either a typo or an
|
|
265
|
+
# attempt to smuggle markup into state the dashboard later renders.
|
|
266
|
+
_MODEL_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def is_valid_model_id(model: str) -> bool:
|
|
270
|
+
return bool(_MODEL_ID_RE.match(model))
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def mask_key(key: str) -> str:
|
|
274
|
+
"""Redact a key for display. Enough prefix to tell keys apart, never enough to use."""
|
|
275
|
+
if not isinstance(key, str):
|
|
276
|
+
return "?"
|
|
277
|
+
return key if len(key) <= 12 else f"{key[:5]}…{key[-4:]}"
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def load_saved_keys_file() -> list[str]:
|
|
281
|
+
"""Load the key pool, rejecting anything that is not a list of non-empty strings.
|
|
282
|
+
|
|
283
|
+
A hand-edited ``keys.json`` that holds a dict or a number used to flow
|
|
284
|
+
straight into ``Bearer {k}`` and produce an unexplainable 401 loop, so the
|
|
285
|
+
shape is checked here rather than at the point of use.
|
|
286
|
+
"""
|
|
287
|
+
p = config_path()
|
|
288
|
+
try:
|
|
289
|
+
data = json.loads(p.read_text())
|
|
290
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
291
|
+
return []
|
|
292
|
+
if not isinstance(data, list):
|
|
293
|
+
return []
|
|
294
|
+
return [k for k in data if isinstance(k, str) and k.strip()]
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def atomic_write(path: Path, content: str, mode: int = 0o600) -> None:
|
|
298
|
+
"""Write to a unique temp file, fsync, then replace — crash-safe and private.
|
|
299
|
+
|
|
300
|
+
Three things the obvious version gets wrong. The temp file is created with
|
|
301
|
+
an explicit mode, because the default would be 0644 and this function's
|
|
302
|
+
main caller writes API keys. It is fsynced (file *and* directory) before
|
|
303
|
+
the rename, since rename is atomic against readers but not against power
|
|
304
|
+
loss. And the temp name is unique, so two processes — which happens during
|
|
305
|
+
the ~1s restart overlap — cannot clobber each other's staging file.
|
|
306
|
+
"""
|
|
307
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
308
|
+
fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp")
|
|
309
|
+
tmp = Path(tmp_name)
|
|
310
|
+
try:
|
|
311
|
+
os.fchmod(fd, mode)
|
|
312
|
+
with os.fdopen(fd, "w") as fh:
|
|
313
|
+
fh.write(content)
|
|
314
|
+
fh.flush()
|
|
315
|
+
os.fsync(fh.fileno())
|
|
316
|
+
# os.replace, not Path.rename: rename() raises if the destination
|
|
317
|
+
# exists on Windows, which config_dir() explicitly supports.
|
|
318
|
+
os.replace(tmp, path)
|
|
319
|
+
# Directory fsync makes the rename itself durable. Not possible on
|
|
320
|
+
# Windows (a directory cannot be opened), where it is also not needed.
|
|
321
|
+
if sys.platform != "win32":
|
|
322
|
+
dir_fd = os.open(str(path.parent), os.O_RDONLY)
|
|
323
|
+
try:
|
|
324
|
+
os.fsync(dir_fd)
|
|
325
|
+
finally:
|
|
326
|
+
os.close(dir_fd)
|
|
327
|
+
except BaseException:
|
|
328
|
+
tmp.unlink(missing_ok=True)
|
|
329
|
+
raise
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def save_keys_file(keys: list[str], create_backup: bool = True) -> None:
|
|
333
|
+
"""Save the key pool 0600, backing up the previous file first.
|
|
334
|
+
|
|
335
|
+
Returns nothing but raises on write failure: losing the key file silently
|
|
336
|
+
is worse than a traceback, because the user has no way to notice until the
|
|
337
|
+
next start.
|
|
338
|
+
"""
|
|
339
|
+
clean = [k for k in keys if isinstance(k, str) and k.strip()]
|
|
340
|
+
content = json.dumps(clean, indent=2)
|
|
341
|
+
cfg_path = config_path()
|
|
342
|
+
|
|
343
|
+
if create_backup and cfg_path.exists():
|
|
344
|
+
try:
|
|
345
|
+
from .safe_file import create_backup as make_backup
|
|
346
|
+
|
|
347
|
+
make_backup(cfg_path)
|
|
348
|
+
except OSError as exc:
|
|
349
|
+
# A failed backup is survivable, an unexplained one is not: if the
|
|
350
|
+
# disk is full the write below will fail too, and the user needs
|
|
351
|
+
# both facts to understand what happened.
|
|
352
|
+
print(f"⚠ keys.json backup failed: {exc}", flush=True)
|
|
353
|
+
|
|
354
|
+
atomic_write(cfg_path, content)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def control_token_path() -> Path:
|
|
358
|
+
return config_dir() / "control_token"
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
_control_token: str | None = None
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def control_token() -> str:
|
|
365
|
+
"""The shared secret that authenticates the control plane.
|
|
366
|
+
|
|
367
|
+
Checking Origin and Host is not authentication. Those headers are
|
|
368
|
+
unforgeable *by a browser*, which is why they stop a malicious page — but
|
|
369
|
+
any script can set them to whatever it likes, so anything that can open a
|
|
370
|
+
TCP connection to the port could reach /api/* by sending
|
|
371
|
+
``Host: localhost:1919``. Loopback binding does not help either: a
|
|
372
|
+
forwarder (``tailscale serve``, an SSH tunnel, a container port map)
|
|
373
|
+
connects from 127.0.0.1 on the client's behalf, so the proxy cannot tell
|
|
374
|
+
the traffic apart by source address.
|
|
375
|
+
|
|
376
|
+
A secret in a 0600 file can: possession of it means the caller could read
|
|
377
|
+
a file only this UID can read. It is generated once and reused, so the
|
|
378
|
+
dashboard keeps working across restarts.
|
|
379
|
+
"""
|
|
380
|
+
global _control_token
|
|
381
|
+
if _control_token:
|
|
382
|
+
return _control_token
|
|
383
|
+
p = control_token_path()
|
|
384
|
+
try:
|
|
385
|
+
existing = p.read_text().strip()
|
|
386
|
+
if len(existing) >= 32:
|
|
387
|
+
_control_token = existing
|
|
388
|
+
return _control_token
|
|
389
|
+
except (FileNotFoundError, OSError):
|
|
390
|
+
pass
|
|
391
|
+
_control_token = secrets.token_urlsafe(32)
|
|
392
|
+
atomic_write(p, _control_token + "\n")
|
|
393
|
+
return _control_token
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def harden_config_permissions() -> list[str]:
|
|
397
|
+
"""Tighten anything an older version left world-readable. Returns what changed.
|
|
398
|
+
|
|
399
|
+
Versions before this one wrote keys.json and its backups 0644, so upgrading
|
|
400
|
+
is not enough — the files already on disk stay readable by every account on
|
|
401
|
+
the machine until something repairs them. This runs at startup.
|
|
402
|
+
"""
|
|
403
|
+
if sys.platform == "win32":
|
|
404
|
+
return []
|
|
405
|
+
fixed: list[str] = []
|
|
406
|
+
d = config_dir()
|
|
407
|
+
for p in d.glob("keys*.json"):
|
|
408
|
+
try:
|
|
409
|
+
if p.stat().st_mode & 0o077:
|
|
410
|
+
p.chmod(0o600)
|
|
411
|
+
fixed.append(p.name)
|
|
412
|
+
except OSError:
|
|
413
|
+
continue
|
|
414
|
+
return fixed
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def presets_path() -> Path:
|
|
418
|
+
return config_dir() / "presets.json"
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def load_saved_presets() -> list:
|
|
422
|
+
p = presets_path()
|
|
423
|
+
try:
|
|
424
|
+
data = json.loads(p.read_text())
|
|
425
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
426
|
+
return []
|
|
427
|
+
if not isinstance(data, list):
|
|
428
|
+
return []
|
|
429
|
+
return [m for m in data if isinstance(m, str) and is_valid_model_id(m)]
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def save_presets_file(presets: list) -> None:
|
|
433
|
+
atomic_write(presets_path(), json.dumps(presets, indent=2))
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
# The stop flag used to be persisted here (stop_flag_path / save_stop_flag /
|
|
437
|
+
# check_stop_flag / clear_stop_flag). Nothing ever read it: ProxyState.running
|
|
438
|
+
# starts True unconditionally. Wiring it up would have been worse than deleting
|
|
439
|
+
# it — a user who paused the proxy from the dashboard would find it silently
|
|
440
|
+
# refusing every request after a reboot, with no indication why. Stop is a
|
|
441
|
+
# runtime toggle and stays one.
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def active_model_path() -> Path:
|
|
445
|
+
return config_dir() / "active_model"
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def save_active_model(model: str) -> None:
|
|
449
|
+
atomic_write(active_model_path(), model)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def load_active_model() -> str:
|
|
453
|
+
p = active_model_path()
|
|
454
|
+
try:
|
|
455
|
+
return p.read_text().strip()
|
|
456
|
+
except (FileNotFoundError, OSError):
|
|
457
|
+
return ""
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def opencode_config_path() -> Path:
|
|
461
|
+
xdg = os.environ.get("XDG_CONFIG_HOME", "")
|
|
462
|
+
if xdg:
|
|
463
|
+
return Path(xdg) / "opencode" / "opencode.json"
|
|
464
|
+
return Path.home() / ".config" / "opencode" / "opencode.json"
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def jcode_config_path() -> Path:
|
|
468
|
+
"""Path to the Jcode config file (~/.jcode/config.toml).
|
|
469
|
+
|
|
470
|
+
Jcode uses a fixed location (not XDG), the same on every platform.
|
|
471
|
+
"""
|
|
472
|
+
return Path.home() / ".jcode" / "config.toml"
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def upstream_endpoints() -> list[str]:
|
|
476
|
+
"""Upstream endpoints for the router: env beats file beats default.
|
|
477
|
+
|
|
478
|
+
The list keeps its order: the first endpoint is the preferred one, the
|
|
479
|
+
others are fallbacks for failover.
|
|
480
|
+
"""
|
|
481
|
+
env = os.environ.get("OPENVIDIA_UPSTREAM_ENDPOINTS", "").strip()
|
|
482
|
+
if env:
|
|
483
|
+
endpoints = [e.strip().rstrip("/") + "/" for e in env.split(",") if e.strip()]
|
|
484
|
+
if endpoints:
|
|
485
|
+
return endpoints
|
|
486
|
+
try:
|
|
487
|
+
data = json.loads((config_dir() / "endpoints.json").read_text())
|
|
488
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError, TypeError):
|
|
489
|
+
return [UPSTREAM_BASE]
|
|
490
|
+
if not isinstance(data, dict):
|
|
491
|
+
return [UPSTREAM_BASE]
|
|
492
|
+
raw = data.get("endpoints")
|
|
493
|
+
if not isinstance(raw, list):
|
|
494
|
+
return [UPSTREAM_BASE]
|
|
495
|
+
endpoints = [e for e in raw if isinstance(e, str) and e.strip()]
|
|
496
|
+
if not endpoints:
|
|
497
|
+
return [UPSTREAM_BASE]
|
|
498
|
+
return [e.strip().rstrip("/") + "/" for e in endpoints]
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def redis_url() -> str:
|
|
502
|
+
"""Redis URL for cross-node sync; empty string means standalone mode."""
|
|
503
|
+
env = os.environ.get("OPENVIDIA_REDIS_URL", "").strip()
|
|
504
|
+
if env:
|
|
505
|
+
return env
|
|
506
|
+
try:
|
|
507
|
+
data = json.loads((config_dir() / "redis_config.json").read_text())
|
|
508
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError, TypeError):
|
|
509
|
+
return ""
|
|
510
|
+
if not isinstance(data, dict):
|
|
511
|
+
return ""
|
|
512
|
+
url = data.get("url", "")
|
|
513
|
+
return url if isinstance(url, str) else ""
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""In-memory cache for /v1/embeddings responses.
|
|
2
|
+
|
|
3
|
+
Embedding vectors are deterministic per (model, input): caching the upstream
|
|
4
|
+
body avoids burning free-tier RPM budget on repeated identical requests.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
_CANONICAL_SEP = "\x1f"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EmbeddingCache:
|
|
17
|
+
"""TTL cache keyed by sha256 of the canonical (model, inputs) tuple.
|
|
18
|
+
|
|
19
|
+
The canonical form keeps the input ORDER (embedding order matters), so the
|
|
20
|
+
digest covers the exact sequence the caller asked for.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, max_entries: int = 1000, ttl_s: float = 300.0) -> None:
|
|
24
|
+
self.max_entries = max_entries
|
|
25
|
+
self.ttl_s = ttl_s
|
|
26
|
+
self._entries: dict[str, tuple[float, bytes]] = {}
|
|
27
|
+
self.hits = 0
|
|
28
|
+
self.misses = 0
|
|
29
|
+
|
|
30
|
+
@staticmethod
|
|
31
|
+
def _key(model: str, inputs: str | list[str]) -> str:
|
|
32
|
+
if isinstance(inputs, str):
|
|
33
|
+
raw = model + _CANONICAL_SEP + inputs
|
|
34
|
+
else:
|
|
35
|
+
raw = model + _CANONICAL_SEP + json.dumps(inputs, ensure_ascii=True)
|
|
36
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
37
|
+
|
|
38
|
+
def get(self, model: str, inputs: str | list[str]) -> bytes | None:
|
|
39
|
+
key = self._key(model, inputs)
|
|
40
|
+
entry = self._entries.get(key)
|
|
41
|
+
if entry is None:
|
|
42
|
+
self.misses += 1
|
|
43
|
+
return None
|
|
44
|
+
inserted_at, body = entry
|
|
45
|
+
if time.monotonic() - inserted_at > self.ttl_s:
|
|
46
|
+
self._entries.pop(key, None)
|
|
47
|
+
self.misses += 1
|
|
48
|
+
return None
|
|
49
|
+
self.hits += 1
|
|
50
|
+
return body
|
|
51
|
+
|
|
52
|
+
def set(self, model: str, inputs: str | list[str], body: bytes) -> None:
|
|
53
|
+
# Pruning is lazy: once the threshold is exceeded the oldest 10% is dropped,
|
|
54
|
+
# with no full scan on every insert.
|
|
55
|
+
if len(self._entries) >= self.max_entries:
|
|
56
|
+
oldest = sorted(self._entries.items(), key=lambda kv: kv[1][0])[
|
|
57
|
+
: max(1, self.max_entries // 10)
|
|
58
|
+
]
|
|
59
|
+
for old_key, _ in oldest:
|
|
60
|
+
self._entries.pop(old_key, None)
|
|
61
|
+
self._entries[self._key(model, inputs)] = (time.monotonic(), body)
|
|
62
|
+
|
|
63
|
+
def clear(self) -> None:
|
|
64
|
+
self._entries.clear()
|
|
65
|
+
self.hits = 0
|
|
66
|
+
self.misses = 0
|
|
67
|
+
|
|
68
|
+
def as_dict(self) -> dict[str, int]:
|
|
69
|
+
return {"entries": len(self._entries), "hits": self.hits, "misses": self.misses}
|