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/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}