Skip to content

Commit dce88e3

Browse files
author
ciroautuori
committed
feat: 2.1.0 — graph engine, adaptive RPM, multi-endpoint router, redis sync, embedding cache, provider fallback
1 parent d2a4591 commit dce88e3

20 files changed

Lines changed: 2027 additions & 60 deletions

‎CHANGELOG.md‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ All notable changes to OpenVidia will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8-
## [Unreleased]
8+
## [2.1.0] - 2026-08-01
99

1010
### Added
1111
- **Thinking toggle** — `auto` / `on` / `off` next to the active model. A
@@ -37,6 +37,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3737
- CONTRIBUTING.md guide for new contributors
3838
- SECURITY.md with vulnerability disclosure process
3939
- Error logging improvements for better debugging
40+
- **Graph Engine** — `openvidia.graph_engine`: a `Hub` with mailboxes, `run_agent`
41+
with `max_iterations` / `budget_tokens` / `timeout_s` caps, spawn-and-kill
42+
subagent tools with a depth bound, and `generate_and_verify` generator-verifier
43+
loops with explicit rubrics (`APPROVED` / `REJECTED`). 10 regression tests in
44+
`tests/test_graph_engine.py`
45+
- **Per-key × per-model scoring** — `KeyModelHealth` learns a composite score
46+
(success rate + median time to first token) from live traffic; candidate
47+
selection now prefers the best key for the requested model
48+
- **Adaptive RPM ceiling** — each key's sliding window learns the real ceiling
49+
from `Retry-After` headers on 429s and never spends the full 28 RPM budget
50+
against a tighter upstream limit
51+
- **Multi-endpoint NVIDIA routing** — extra upstream endpoints via
52+
`endpoints.json` or `OPENVIDIA_UPSTREAM_ENDPOINTS`; 60s blacklist on ≥500
53+
errors, automatic recovery on success
54+
- **Multi-node sync via Redis (opt-in)** — cooldowns, invalid keys, pool
55+
throttling and model circuit breakers shared across instances over pub/sub
56+
(`redis_config.json` / `OPENVIDIA_REDIS_URL`, extra `openvidia[redis]`)
57+
- **Embedding cache** — in-memory cache for `/v1/embeddings` (TTL 300s, SHA-256
58+
keyed by model + input); hit/miss counters on `/ops/health`
59+
- **Free-tier provider fallback** — when every NVIDIA key is exhausted, the
60+
catch-all fails over to OpenAI-compatible providers declared in
61+
`providers.json` (unresolved keys skip the provider silently)
62+
- **Ops endpoints** — `/ops/keys` and `/ops/health`, protected by the
63+
control-plane token, exposing pool, models, endpoints, embedding cache and
64+
Redis state
65+
- Jcode as a first-class CLI target (`openvidia setup` configures it)
66+
- **Smart key rotation** — rotation ordered by in-flight cost, per-key RPM and
67+
consecutive failures, so concurrent traffic spreads across the pool instead
68+
of piling onto key[0]
69+
- **Network-error circuit breaker** — three consecutive network errors stop
70+
rotation (503) instead of burning every key; 504 timeouts cool the key for
71+
30s without consuming the attempt budget
4072

4173
### Changed
4274
- Compaction serves a cached summary plus every later message verbatim while
@@ -48,6 +80,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4880
- Concurrent requests on one conversation share a single summarize
4981
- Version bumped from 2.0.0 to 1.0.0 (first stable release)
5082
- Test suite uses pytest with async support
83+
- Key selection is model-aware: candidates are ordered by a per-key × per-model
84+
score learned from live traffic (see Added)
85+
- The test suite ships 297 regression tests (proxy, shims, compaction, graph
86+
engine, delivery features)
5187

5288
### Removed
5389
- **The pinned default model.** `DEFAULT_MODEL = "deepseek-ai/deepseek-v4-pro"`

‎README.md‎

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
![Ruff](https://img.shields.io/badge/Ruff-passed-261230?logo=ruff&logoColor=white)
77
![Stars](https://img.shields.io/github/stars/ciroautuori/openvidia?style=social)
88
![Last Commit](https://img.shields.io/github/last-commit/ciroautuori/openvidia)
9-
![Version](https://img.shields.io/badge/version-2.0.0-green)
9+
![Version](https://img.shields.io/badge/version-2.1.0-green)
1010

1111
**Multi-key proxy for NVIDIA NIM with a native desktop dashboard.**
1212

@@ -123,10 +123,16 @@ NVIDIA's free NIM tier limits each API key to ~40 RPM. Aggressive bursts trigger
123123
- **Per-key cooldown timers** — on real 429s, trusts `Retry-After` exactly (no multiplication); distinguishes transient worker saturation (`ResourceExhausted`) from true RPM limits and never burns key cooldowns for the former
124124
- **Network-error circuit breaker** — `ConnectTimeout`/`ConnectError` don't trigger key cooldown (the key is fine). After 3 consecutive network errors across different keys, rotation stops entirely and returns 503 (the network is down, no key can help)
125125
- **504 model timeouts don't burn keys** — gateway timeouts cool the key for 30s but don't consume the attempt budget, since the model (not the key) is the bottleneck
126-
- **Sliding-window RPM limiting** — keeps each key under 28 RPM (safe margin below 40)
126+
- **Sliding-window RPM limiting** — keeps each key under 28 RPM (safe margin below 40); the ceiling self-tunes down from real `Retry-After` headers, so the pool never spends its full budget against a tighter upstream limit
127127
- **Health checks** — revives keys whose cooldowns have expired, but skips 401/403 invalid keys (dead keys stay dead)
128128
- **No silent model substitution** — the model you select is the model that answers; if it fails on every key you are told so, never handed output from a different model
129129
- **Auto-compaction** — summarizes long histories so requests never fail on context overflow ([details](#auto-compaction))
130+
- **Per-key × per-model scoring** — rotation is ordered by a composite score (success rate + median time to first token) learned per key and per model from live traffic: the best key for the requested model goes first
131+
- **Multi-endpoint routing** — extra NVIDIA endpoints (`endpoints.json` or `OPENVIDIA_UPSTREAM_ENDPOINTS`), a 60s blacklist on ≥500 errors, automatic recovery
132+
- **Multi-node sync via Redis (opt-in)** — cooldowns, invalid keys, pool throttling and model circuit breakers shared across instances ([details](#resilience--scale))
133+
- **Embedding cache** — `/v1/embeddings` hits are served from memory (TTL 300s) without touching upstream quota
134+
- **Free-tier provider fallback** — when every NVIDIA key is exhausted, requests fail over to OpenAI-compatible providers declared in `providers.json` ([details](#resilience--scale))
135+
- **Graph engine** — programmatic multi-agent orchestration (Hub, run_agent, generator-verifier loops) on top of the proxy ([details](#graph-engine))
130136

131137
---
132138

@@ -507,6 +513,9 @@ update-desktop-database ~/.local/share/applications/
507513
| `timeouts.json` | Upstream timeouts (optional — see [Slow models](#slow-models)) |
508514
| `model_limits.json` | Context windows the proxy learned by itself — never edit by hand |
509515
| `model_options.json` | Reasoning toggle + the payload used to express it (see [Thinking](#thinking-reasoning-toggle)) |
516+
| `endpoints.json` | Extra NVIDIA upstream endpoints (optional — see [Resilience & Scale](#resilience--scale)) |
517+
| `redis_config.json` | Redis URL for multi-node sync (optional — `OPENVIDIA_REDIS_URL` also works) |
518+
| `providers.json` | Free-tier fallback providers (optional — name, base URL, env var for the key, model map) |
510519
511520
Add keys via the dashboard (**Keys** section) or edit `keys.json`:
512521
@@ -663,6 +672,96 @@ DEFAULT_COOLDOWN = 30.0 # Network errors, unknown 5xx
663672
| `POST` | `/api/restart` | Zero-downtime restart (spawn new, kill old) |
664673
| `GET` | `/api/logs/stream` | SSE log stream (real-time) |
665674
675+
### Ops (control plane — token protected)
676+
677+
| Method | Path | Description |
678+
|--------|------|-------------|
679+
| `GET` | `/ops/keys` | Per-key status: cooldown, RPM + learned ceiling, per-model score, in-flight |
680+
| `GET` | `/ops/health` | Pool, per-model health, endpoints, embedding cache, Redis sync, recent logs |
681+
682+
---
683+
684+
## Graph Engine
685+
686+
Programmatic multi-agent orchestration on top of the proxy, following the
687+
[graph-engine coordination patterns](https://www.anthropic.com/engineering/graph-engine-coordination):
688+
a single `Hub` owns mailboxes and tasks, agents run with hard caps, and workers
689+
can be spawned, messaged and killed from inside the loop. Runs on any model the
690+
proxy serves.
691+
692+
```python
693+
import asyncio
694+
from openvidia import Hub, Provider, ToolSpec, run_agent
695+
696+
async def add(args, ctx):
697+
return str(args["a"] + args["b"])
698+
699+
async def main():
700+
hub = Hub(default_provider=Provider()) # http://127.0.0.1:1919/v1 — the proxy above
701+
try:
702+
result = await run_agent(
703+
hub, "math",
704+
system="You are a calculator. Always use the add tool.",
705+
first_turn="What is 5 + 2?",
706+
tools=[ToolSpec(
707+
name="add", description="Sum two integers",
708+
handler=add,
709+
parameters={"type": "object", "properties": {
710+
"a": {"type": "integer"}, "b": {"type": "integer"}},
711+
"required": ["a", "b"]},
712+
)],
713+
)
714+
print(result)
715+
finally:
716+
await hub.close()
717+
```
718+
719+
Highlights: `run_agent` caps every run with `max_iterations`, `budget_tokens`
720+
and `timeout_s`; `graph_tools(hub, worker_factory=...)` provides
721+
`get_status`, `send_message`, `wait_for_message`, `create_subagents` (depth-bound:
722+
workers cannot spawn workers) and `kill_subagents`; `generate_and_verify` runs
723+
generator → verifier loops with explicit rubrics until `APPROVED` or
724+
`max_rounds`; providers are swappable per worker (a `Provider` or a
725+
`Callable[[str], Provider]`). 10 regression tests ship in `tests/test_graph_engine.py`.
726+
727+
---
728+
729+
## Resilience & Scale
730+
731+
Beyond the core pool, OpenVidia is built to survive upstream trouble and to
732+
scale across machines.
733+
734+
- **Multi-endpoint routing** — by default requests go to
735+
`integrate.api.nvidia.com`; add endpoints via `endpoints.json` or
736+
`OPENVIDIA_UPSTREAM_ENDPOINTS` (comma-separated). A status ≥500 blacklists an
737+
endpoint for 60s; a success revives it. Deterministic client errors never
738+
retry on another endpoint.
739+
- **Multi-node sync (Redis, opt-in)** — one proxy per machine, shared state:
740+
cooldowns, invalid keys, pool throttling and model circuit breakers propagate
741+
over pub/sub so a 429 learned on node A protects node B. Enable with
742+
`OPENVIDIA_REDIS_URL` or `redis_config.json` (`{"url": "..."}`); install the
743+
extra with `pip install "openvidia[redis]"`.
744+
- **Embedding cache** — repeated `/v1/embeddings` requests (same model + input)
745+
are answered from an in-memory cache (TTL 300s) keyed by SHA-256; hit/miss
746+
counters are exposed on `/ops/health`.
747+
- **Free-tier provider fallback** — when every NVIDIA key is exhausted, the
748+
catch-all fails over to OpenAI-compatible providers listed in
749+
`providers.json`:
750+
751+
```json
752+
[
753+
{
754+
"name": "glm",
755+
"base_url": "https://api.example.com/v1",
756+
"api_key_env": "GLM_API_KEY",
757+
"models": { "deepseek-ai/deepseek-v4-flash": "glm-5" }
758+
}
759+
]
760+
```
761+
762+
A provider without its `api_key_env` set in the environment is skipped
763+
silently; model names are rewritten through the `models` map.
764+
666765
---
667766
668767
## Tech Stack
@@ -675,6 +774,7 @@ DEFAULT_COOLDOWN = 30.0 # Network errors, unknown 5xx
675774
- **[tomlkit](https://github.com/sdispater/tomlkit)** — TOML config editing (preserves comments)
676775
- **Vanilla HTML/CSS/JS** — zero frontend build, no node_modules
677776
- **Python 3.12+** — single process, no external services
777+
- **[redis](https://redis.io/)** (optional) — multi-node state sync via `redis-py`
678778
679779
---
680780

‎docs/wayfinder/map.md‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Graph Engine: mappa wayfinder
2+
3+
Label: `wayfinder:map` — tracker local-markdown (GitHub issues disabilitati sul repo).
4+
5+
## Destination
6+
7+
Modulo `openvidia/graph_engine.py` (~400 righe, async) che implementa il Graph Loop su DeepSeek locale (localhost:1919): Hub + run_agent + tool spawn/status/collect/kill + generate_and_verify, con test pytest verdi e ruff pulito.
8+
9+
## Notes
10+
11+
- domain: python async, httpx, orchestrazione agenti (pattern Anthropic: orchestrator-subagent, generator-verifier, contesto fresco, depth bound)
12+
- skill: /grilling, /domain-modeling, /tdd
13+
- OVERRIDE: effort in **full-delivery** — i ticket si risolvono nella sessione stessa, non solo decisioni. Chart minimale, poi implementazione completa.
14+
15+
## Decisions so far
16+
17+
- [wf-001 full-delivery approved](tickets/wf-001-full-delivery.md) — l'utente ha richiesto mappa + implementazione completa nella stessa sessione ("E POI PARTI FULL DELIVERY").
18+
- [wf-002 API pubblica graph_engine](tickets/wf-002-API-pubblica-graph-engine.md) — `openvidia/graph_engine.py` implementato e verificato: Hub + run_agent + graph_tools + generate_and_verify, 10/10 test, ruff pulito, export da `__init__.py`.
19+
20+
## Not yet specified
21+
22+
- Provider alternativi (non-DeepSeek) via env/CLI
23+
- Persistenza sessioni graph su disco
24+
25+
## Out of scope
26+
27+
- Multi-nodo Redis / HTTP2 pool upstream (roadmap separata del proxy)
28+
- Frontend dashboard del graph engine
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# wf-001 — full-delivery approved
2+
3+
Label: `wayfinder:task` · HITL (deciso dall'utente in sessione) · **CLOSED**
4+
5+
## Question
6+
7+
L'utente carica wayfinder e dice "E POI PARTI FULL DELIVERY": l'effort Graph Engine deve essere sia mappato sia implementato per intero nella stessa sessione.
8+
9+
## Resolution
10+
11+
Approvato. La mappa (docs/wayfinder/map.md) è chart minima, la costruzione del grafo parte subito: graph_engine.py + tests + verify pytest/ruff.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# wf-002 — API pubblica graph_engine
2+
3+
Label: `wayfinder:task` · AFK · **CLOSED**
4+
5+
blocked-by: wf-001
6+
7+
## Question
8+
9+
Quale public API espone `openvidia/graph_engine.py`? Design già deciso in sessione: `Hub` (register/new_name/close/snapshot), `run_agent`, `generate_and_verify`, `ToolSpec`/`WorkerSpec`/`Provider` dataclass, `graph_tools` (get_status, send_message, wait_for_message, create_subagents, kill_subagents), export da `__init__.py`.
10+
11+
## Resolution
12+
13+
Implementata e verificata (10/10 test, ruff pulito): `openvidia/graph_engine.py` con `Provider` (chat async OpenAI-style), `ToolSpec`, `WorkerSpec`, `Hub` (status/mailbox/task/events), `run_agent` (loop con max_iterations, budget_tokens, timeout, crash su risposta vuota, tool error recovery), `graph_tools` (closure con factory per spawn/kill, depth bound: i worker non spawnano), `generate_and_verify` (rubric esplicita, APPROVED/REJECTED, feedback nel round successivo). Export public da `openvidia/__init__.py`. Test: `tests/test_graph_engine.py` (10 test, ScriptedProvider).

‎openvidia/__init__.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,25 @@
66
"""
77

88
from __future__ import annotations
9+
10+
from openvidia.graph_engine import (
11+
AgentCtx,
12+
Hub,
13+
Provider,
14+
ToolSpec,
15+
WorkerSpec,
16+
generate_and_verify,
17+
graph_tools,
18+
run_agent,
19+
)
20+
21+
__all__ = [
22+
"AgentCtx",
23+
"Hub",
24+
"Provider",
25+
"ToolSpec",
26+
"WorkerSpec",
27+
"generate_and_verify",
28+
"graph_tools",
29+
"run_agent",
30+
]

‎openvidia/anthropic_shim.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,7 @@ async def _stream_anthropic(
382382

383383
state.stats.success += 1
384384
state.stats.record_key_usage(used_key, ok=True)
385+
state.record_key_model_result(used_key, chat_payload.get("model", ""), ok=True)
385386
if used_idx is not None:
386387
state.log_cb(f"✔ key[{used_idx}] OK")
387388

@@ -701,6 +702,7 @@ async def handle_anthropic_messages(
701702

702703
state.stats.success += 1
703704
state.stats.record_key_usage(used_key, ok=True)
705+
state.record_key_model_result(used_key, chat_payload.get("model", ""), ok=True)
704706
if used_idx is not None:
705707
state.log_cb(f"✔ key[{used_idx}] OK")
706708

‎openvidia/config.py‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,3 +465,44 @@ def jcode_config_path() -> Path:
465465
Jcode uses a fixed location (not XDG), the same on every platform.
466466
"""
467467
return Path.home() / ".jcode" / "config.toml"
468+
469+
470+
def upstream_endpoints() -> list[str]:
471+
"""Upstream endpoints for the router: env beats file beats default.
472+
473+
La lista si mantiene ordinata: il primo endpoint è il preferito, gli
474+
altri sono fallback per il failover.
475+
"""
476+
env = os.environ.get("OPENVIDIA_UPSTREAM_ENDPOINTS", "").strip()
477+
if env:
478+
endpoints = [e.strip().rstrip("/") + "/" for e in env.split(",") if e.strip()]
479+
if endpoints:
480+
return endpoints
481+
try:
482+
data = json.loads((config_dir() / "endpoints.json").read_text())
483+
except (FileNotFoundError, json.JSONDecodeError, OSError, TypeError):
484+
return [UPSTREAM_BASE]
485+
if not isinstance(data, dict):
486+
return [UPSTREAM_BASE]
487+
raw = data.get("endpoints")
488+
if not isinstance(raw, list):
489+
return [UPSTREAM_BASE]
490+
endpoints = [e for e in raw if isinstance(e, str) and e.strip()]
491+
if not endpoints:
492+
return [UPSTREAM_BASE]
493+
return [e.strip().rstrip("/") + "/" for e in endpoints]
494+
495+
496+
def redis_url() -> str:
497+
"""Redis URL for cross-node sync; empty string means standalone mode."""
498+
env = os.environ.get("OPENVIDIA_REDIS_URL", "").strip()
499+
if env:
500+
return env
501+
try:
502+
data = json.loads((config_dir() / "redis_config.json").read_text())
503+
except (FileNotFoundError, json.JSONDecodeError, OSError, TypeError):
504+
return ""
505+
if not isinstance(data, dict):
506+
return ""
507+
url = data.get("url", "")
508+
return url if isinstance(url, str) else ""

0 commit comments

Comments
 (0)