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.
@@ -0,0 +1,466 @@
1
+ """Graph engine for OpenVIDIA: Hub + run_agent + graph tools, DeepSeek local.
2
+
3
+ Implements the Anthropic graph-loop patterns (orchestrator-subagent,
4
+ generator-verifier, fresh context per worker, depth bound) on top of the
5
+ OpenVIDIA proxy at localhost:1919.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ from collections.abc import Awaitable, Callable
13
+ from dataclasses import dataclass
14
+ from typing import Any
15
+
16
+ import httpx
17
+
18
+ DEFAULT_BASE_URL = "http://127.0.0.1:1919/v1"
19
+ # The proxy resolves the "openvidia" alias to the active model: this survives
20
+ # model switches without touching the client (a hardcoded upstream ID would 404).
21
+ DEFAULT_MODEL = "openvidia"
22
+ MAX_BUDGET_EXHAUSTED = "<budget exhausted>"
23
+ MAX_ITERATIONS_REACHED = "<max iterations reached>"
24
+ MAX_TIMEOUT = "<timeout>"
25
+
26
+
27
+ @dataclass
28
+ class Provider:
29
+ """OpenAI-compatible chat provider pointing at the OpenVIDIA proxy."""
30
+
31
+ base_url: str = DEFAULT_BASE_URL
32
+ api_key: str = "ignored"
33
+ model: str = DEFAULT_MODEL
34
+ timeout_s: float = 120.0
35
+ temperature: float | None = None
36
+
37
+ async def chat(
38
+ self,
39
+ messages: list[dict[str, Any]],
40
+ tools: list[dict[str, Any]] | None = None,
41
+ client: httpx.AsyncClient | None = None,
42
+ ) -> dict[str, Any]:
43
+ payload: dict[str, Any] = {"model": self.model, "messages": messages}
44
+ if tools:
45
+ payload["tools"] = tools
46
+ if self.temperature is not None:
47
+ payload["temperature"] = self.temperature
48
+ own_client = client is None
49
+ # Close the client only if it was created here: whoever passes one (hub.client) owns it.
50
+ hc = client or httpx.AsyncClient(timeout=self.timeout_s)
51
+ try:
52
+ resp = await hc.post(
53
+ f"{self.base_url}/chat/completions",
54
+ json=payload,
55
+ headers={"Authorization": f"Bearer {self.api_key}"},
56
+ )
57
+ resp.raise_for_status()
58
+ return resp.json()
59
+ finally:
60
+ if own_client:
61
+ await hc.aclose()
62
+
63
+
64
+ @dataclass
65
+ class ToolSpec:
66
+ """A tool an agent can call: name, description, async handler, JSON schema."""
67
+
68
+ name: str
69
+ description: str
70
+ handler: Callable[[dict[str, Any], AgentCtx], Awaitable[str]]
71
+ parameters: dict[str, Any] | None = None
72
+
73
+ def schema(self) -> dict[str, Any]:
74
+ return {
75
+ "type": "function",
76
+ "function": {
77
+ "name": self.name,
78
+ "description": self.description,
79
+ "parameters": self.parameters or {"type": "object", "properties": {}},
80
+ },
81
+ }
82
+
83
+
84
+ @dataclass
85
+ class AgentCtx:
86
+ """Context handed to tool handlers: the hub plus who is calling."""
87
+
88
+ hub: Hub
89
+ name: str
90
+
91
+
92
+ @dataclass
93
+ class WorkerSpec:
94
+ """Spec for a spawnable worker: system prompt, tools, provider, limits."""
95
+
96
+ system: str
97
+ tools: tuple[ToolSpec, ...] = ()
98
+ provider: Provider | Callable[[str], Provider] | None = None
99
+ max_iterations: int = 30
100
+ budget_tokens: int | None = None
101
+ timeout_s: float = 300.0
102
+
103
+
104
+ class Hub:
105
+ """Registry of agents: statuses, mailboxes, tasks, events."""
106
+
107
+ def __init__(
108
+ self,
109
+ *,
110
+ default_provider: Provider | None = None,
111
+ client: httpx.AsyncClient | None = None,
112
+ ) -> None:
113
+ self.default_provider = default_provider or Provider()
114
+ self.client = client
115
+ self._status: dict[str, str] = {}
116
+ self._mailboxes: dict[str, asyncio.Queue] = {}
117
+ self._tasks: dict[str, asyncio.Task] = {}
118
+ self._events: list[dict[str, Any]] = []
119
+ self._counter = 0
120
+
121
+ def register(self, name: str) -> None:
122
+ if name in self._status:
123
+ raise ValueError(f"agent already registered: {name}")
124
+ self._status[name] = "active"
125
+ self._mailboxes[name] = asyncio.Queue()
126
+ self.log_event("register", name)
127
+
128
+ def new_name(self, prefix: str = "helper") -> str:
129
+ while True:
130
+ self._counter += 1
131
+ candidate = f"{prefix}{self._counter}"
132
+ if candidate not in self._status:
133
+ return candidate
134
+
135
+ def mailbox(self, name: str) -> asyncio.Queue:
136
+ try:
137
+ return self._mailboxes[name]
138
+ except KeyError:
139
+ raise KeyError(f"unknown agent: {name}") from None
140
+
141
+ def log_event(self, kind: str, agent: str, detail: str | None = None) -> None:
142
+ self._events.append({"kind": kind, "agent": agent, "detail": detail})
143
+
144
+ def snapshot(self) -> dict[str, Any]:
145
+ return {
146
+ "agents": dict(self._status),
147
+ "events": list(self._events),
148
+ }
149
+
150
+ async def close(self) -> None:
151
+ for task in list(self._tasks.values()):
152
+ task.cancel()
153
+ if self._tasks:
154
+ await asyncio.gather(*self._tasks.values(), return_exceptions=True)
155
+
156
+
157
+ def _extract_text(message: dict[str, Any]) -> str:
158
+ content = message.get("content")
159
+ if isinstance(content, str):
160
+ return content
161
+ if isinstance(content, list):
162
+ return "".join(part.get("text", "") for part in content if isinstance(part, dict))
163
+ return ""
164
+
165
+
166
+ async def run_agent(
167
+ hub: Hub,
168
+ name: str,
169
+ *,
170
+ system: str,
171
+ first_turn: str,
172
+ tools: tuple[ToolSpec, ...] = (),
173
+ extra_dispatch: dict[str, ToolSpec] | None = None,
174
+ provider: Provider | Callable[[str], Provider] | None = None,
175
+ worker_factory: Callable[[str], WorkerSpec] | None = None,
176
+ max_iterations: int = 50,
177
+ budget_tokens: int | None = None,
178
+ timeout_s: float = 600.0,
179
+ ) -> str:
180
+ """Run one agent loop: chat completions until a plain-text reply or a limit."""
181
+ agent_provider = provider or hub.default_provider
182
+ if callable(agent_provider):
183
+ agent_provider = agent_provider(name)
184
+ # Idempotent: spawn already registers the name and mailbox before creating the task,
185
+ # so a worker cancelled before it starts still shows up as registered.
186
+ if name not in hub._status:
187
+ hub.register(name)
188
+
189
+ messages: list[dict[str, Any]] = [
190
+ {"role": "system", "content": system},
191
+ {"role": "user", "content": first_turn},
192
+ ]
193
+ usage = {"prompt": 0, "completion": 0}
194
+
195
+ dispatch: dict[str, ToolSpec] = dict(extra_dispatch or {})
196
+ for spec in tools:
197
+ dispatch[spec.name] = spec
198
+ graph_specs = graph_tools(hub, worker_factory=worker_factory)
199
+ for spec in graph_specs:
200
+ dispatch[spec.name] = spec
201
+ schemas = [spec.schema() for spec in dispatch.values()]
202
+
203
+ hub._tasks[name] = asyncio.current_task()
204
+ try:
205
+ async with asyncio.timeout(timeout_s):
206
+ for _ in range(max_iterations):
207
+ if budget_tokens is not None and (
208
+ usage["prompt"] + usage["completion"] >= budget_tokens
209
+ ):
210
+ hub.log_event("budget", name, str(budget_tokens))
211
+ return MAX_BUDGET_EXHAUSTED
212
+ reply = await agent_provider.chat(messages, tools=schemas, client=hub.client)
213
+ choices = reply.get("choices") or []
214
+ if not choices:
215
+ # Empty response: better to crash than to pass it off as a success.
216
+ raise RuntimeError(f"empty response from {agent_provider.model}")
217
+ choice = choices[0]
218
+ message = choice.get("message", {})
219
+ usage["prompt"] += reply.get("usage", {}).get("prompt_tokens", 0)
220
+ usage["completion"] += reply.get("usage", {}).get("completion_tokens", 0)
221
+ tool_calls = message.get("tool_calls") or []
222
+ if not tool_calls:
223
+ return _extract_text(message)
224
+ messages.append(message)
225
+ for tool_call in tool_calls:
226
+ function = tool_call.get("function", {})
227
+ tool_name = function.get("name")
228
+ try:
229
+ arguments = json.loads(function.get("arguments") or "{}")
230
+ except json.JSONDecodeError:
231
+ arguments = {}
232
+ tool = dispatch.get(tool_name)
233
+ if tool is None:
234
+ result = f"tool '{tool_name}' not found"
235
+ else:
236
+ try:
237
+ result = await tool.handler(arguments, AgentCtx(hub, name))
238
+ except Exception as exc:
239
+ result = f"tool error: {exc}"
240
+ if not isinstance(result, str):
241
+ result = json.dumps(result, ensure_ascii=False)
242
+ messages.append(
243
+ {
244
+ "role": "tool",
245
+ "tool_call_id": tool_call.get("id", ""),
246
+ "content": result,
247
+ }
248
+ )
249
+ return MAX_ITERATIONS_REACHED
250
+ except TimeoutError:
251
+ hub.log_event("timeout", name, str(timeout_s))
252
+ hub._status[name] = "timeout"
253
+ return MAX_TIMEOUT
254
+ except asyncio.CancelledError:
255
+ hub._status[name] = "killed"
256
+ raise
257
+ except Exception as exc:
258
+ hub.log_event("crash", name, str(exc))
259
+ hub._status[name] = "crashed"
260
+ raise
261
+ finally:
262
+ hub._tasks.pop(name, None)
263
+ if hub._status.get(name) == "active":
264
+ hub._status[name] = "done"
265
+
266
+
267
+ async def _get_status(args: dict[str, Any], ctx: AgentCtx) -> str:
268
+ return json.dumps(ctx.hub.snapshot()["agents"], ensure_ascii=False)
269
+
270
+
271
+ async def _send_message(args: dict[str, Any], ctx: AgentCtx) -> str:
272
+ recipients = args.get("recipients") or []
273
+ message = args.get("message") or ""
274
+ if not message:
275
+ return "error: 'message' required"
276
+ # Validate before delivering: no half-delivered messages.
277
+ for recipient in recipients:
278
+ if recipient not in ctx.hub._status:
279
+ return f"error: unknown agent '{recipient}'"
280
+ for recipient in recipients:
281
+ ctx.hub._mailboxes[recipient].put_nowait({"from": ctx.name, "content": message})
282
+ return f"sent to {len(recipients)} recipient(s)"
283
+
284
+
285
+ async def _wait_for_message(args: dict[str, Any], ctx: AgentCtx) -> str:
286
+ timeout = float(args.get("timeout") or 30.0)
287
+ try:
288
+ msg = await asyncio.wait_for(ctx.hub.mailbox(ctx.name).get(), timeout)
289
+ return json.dumps(msg, ensure_ascii=False)
290
+ except TimeoutError:
291
+ return "error: timeout waiting for message"
292
+
293
+
294
+ def _make_create_subagents(
295
+ worker_factory: Callable[[str], WorkerSpec],
296
+ ) -> Callable[[dict[str, Any], AgentCtx], Awaitable[str]]:
297
+ # Closure over the factory: a tool handler does not receive the factory via args.
298
+ async def create_subagents(args: dict[str, Any], ctx: AgentCtx) -> str:
299
+ return await _create_subagents(args, ctx, worker_factory)
300
+
301
+ return create_subagents
302
+
303
+
304
+ async def _create_subagents(
305
+ args: dict[str, Any],
306
+ ctx: AgentCtx,
307
+ worker_factory: Callable[[str], WorkerSpec],
308
+ ) -> str:
309
+ names = args.get("names") or []
310
+ first_turns = args.get("tasks") or {}
311
+ if not names:
312
+ return "error: 'names' required"
313
+ for name in names:
314
+ if name in ctx.hub._status:
315
+ return f"error: agent '{name}' already exists"
316
+ spawned: list[str] = []
317
+ for name in names:
318
+ spec = worker_factory(name)
319
+ spec_provider = spec.provider
320
+ if callable(spec_provider):
321
+ spec_provider = spec_provider(name)
322
+ # Synchronous registration: the status must exist even if the task is
323
+ # cancelled before it starts (the body of a never-started task does not run).
324
+ ctx.hub.register(name)
325
+ # Depth bound: workers run without worker_factory, so they cannot spawn.
326
+ task = asyncio.create_task(
327
+ run_agent(
328
+ ctx.hub,
329
+ name,
330
+ system=spec.system.format(name=name),
331
+ first_turn=first_turns.get(name, ""),
332
+ tools=spec.tools,
333
+ provider=spec_provider,
334
+ max_iterations=spec.max_iterations,
335
+ budget_tokens=spec.budget_tokens,
336
+ timeout_s=spec.timeout_s,
337
+ worker_factory=None,
338
+ )
339
+ )
340
+ ctx.hub._tasks[name] = task
341
+ spawned.append(name)
342
+ return f"spawned: {', '.join(spawned)}"
343
+
344
+
345
+ async def _kill_subagents(args: dict[str, Any], ctx: AgentCtx) -> str:
346
+ names = args.get("names") or []
347
+ killed: list[str] = []
348
+ for name in names:
349
+ task = ctx.hub._tasks.get(name)
350
+ if task is None:
351
+ return f"error: agent '{name}' not running"
352
+ task.cancel()
353
+ await asyncio.gather(task, return_exceptions=True)
354
+ # Covers the never-started task case: the body does not run run_agent's finally.
355
+ ctx.hub._status[name] = "killed"
356
+ killed.append(name)
357
+ return f"killed: {', '.join(killed)}"
358
+
359
+
360
+ def graph_tools(
361
+ hub: Hub, worker_factory: Callable[[str], WorkerSpec] | None = None
362
+ ) -> list[ToolSpec]:
363
+ """Coordination tools: status/messages always; spawn/kill only with a factory."""
364
+ tools: list[ToolSpec] = [
365
+ ToolSpec(
366
+ "get_status",
367
+ "List all agents and their status (active/done/killed/timeout/crashed).",
368
+ _get_status,
369
+ ),
370
+ ToolSpec(
371
+ "send_message",
372
+ "Send a message to one or more agents; they receive it via wait_for_message.",
373
+ _send_message,
374
+ {
375
+ "type": "object",
376
+ "properties": {
377
+ "recipients": {"type": "array", "items": {"type": "string"}},
378
+ "message": {"type": "string"},
379
+ },
380
+ "required": ["recipients", "message"],
381
+ },
382
+ ),
383
+ ToolSpec(
384
+ "wait_for_message",
385
+ "Wait for a message from another agent. Returns the JSON message.",
386
+ _wait_for_message,
387
+ {
388
+ "type": "object",
389
+ "properties": {"timeout": {"type": "number", "default": 30}},
390
+ },
391
+ ),
392
+ ]
393
+ if worker_factory is not None:
394
+ tools.extend(
395
+ [
396
+ ToolSpec(
397
+ "create_subagents",
398
+ "Spawn worker agents (names) with the configured factory; each gets a fresh context.",
399
+ _make_create_subagents(worker_factory),
400
+ {
401
+ "type": "object",
402
+ "properties": {
403
+ "names": {"type": "array", "items": {"type": "string"}},
404
+ "tasks": {"type": "object"},
405
+ },
406
+ "required": ["names"],
407
+ },
408
+ ),
409
+ ToolSpec(
410
+ "kill_subagents",
411
+ "Cancel running worker agents by name.",
412
+ _kill_subagents,
413
+ {
414
+ "type": "object",
415
+ "properties": {"names": {"type": "array", "items": {"type": "string"}}},
416
+ "required": ["names"],
417
+ },
418
+ ),
419
+ ]
420
+ )
421
+ return tools
422
+
423
+
424
+ async def generate_and_verify(
425
+ hub: Hub,
426
+ *,
427
+ name: str,
428
+ system: str,
429
+ base_first_turn: str,
430
+ generator_tools: tuple[ToolSpec, ...],
431
+ criteria: list[str],
432
+ provider: Provider,
433
+ verifier_provider: Provider | None = None,
434
+ max_rounds: int = 3,
435
+ ) -> tuple[str, int, bool]:
436
+ """Generator-verifier loop with explicit rubric; returns (artifact, rounds, approved)."""
437
+ verifier = verifier_provider or provider
438
+ first_turn = base_first_turn
439
+ artifact = ""
440
+ rubric = "\n".join(f"- {criterion}" for criterion in criteria)
441
+ for round_no in range(1, max_rounds + 1):
442
+ artifact = await run_agent(
443
+ hub,
444
+ f"{name}:gen:{round_no}",
445
+ system=system,
446
+ first_turn=first_turn,
447
+ tools=generator_tools,
448
+ provider=provider,
449
+ )
450
+ verdict = await run_agent(
451
+ hub,
452
+ f"{name}:ver:{round_no}",
453
+ system=(
454
+ "You are a strict verifier. Judge the artifact against the rubric. "
455
+ "Reply ONLY with APPROVED or REJECTED: <reason>"
456
+ ),
457
+ first_turn=f"Rubric:\n{rubric}\n\nArtifact:\n{artifact}\n\nVerdict:",
458
+ provider=verifier,
459
+ max_iterations=1,
460
+ )
461
+ verdict = verdict.strip()
462
+ if verdict.startswith("APPROVED"):
463
+ return artifact, round_no, True
464
+ feedback = verdict[len("REJECTED") :].strip() if verdict.startswith("REJECTED") else verdict
465
+ first_turn = f"{base_first_turn}\n\nFeedback from verifier (round {round_no}):\n{feedback}"
466
+ return artifact, max_rounds, False
@@ -0,0 +1,98 @@
1
+ """Optional free-tier provider fallback for /v1/chat/completions.
2
+
3
+ When every NVIDIA key is exhausted, the proxy can hand the request to an
4
+ alternative OpenAI-compatible endpoint (DeepSeek, GLM, ...) configured in
5
+ providers.json. Disabled by default: nothing is loaded unless the user opts
6
+ in with real API keys.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ from dataclasses import dataclass, field
14
+
15
+ import httpx
16
+
17
+ from . import config
18
+
19
+ _TIMEOUT_S = 30.0
20
+
21
+
22
+ @dataclass
23
+ class ProviderConfig:
24
+ name: str
25
+ base_url: str
26
+ api_key: str
27
+ models_map: dict[str, str] = field(default_factory=dict)
28
+
29
+
30
+ def load_provider_configs() -> list[ProviderConfig]:
31
+ """Read providers.json; skip entries whose api_key_env is unresolved."""
32
+ path = config.config_dir() / "providers.json"
33
+ if not path.exists():
34
+ return []
35
+ raw = path.read_text(encoding="utf-8")
36
+ try:
37
+ entries = json.loads(raw)
38
+ except (ValueError, TypeError):
39
+ # A corrupt JSON file must never bring the proxy down: ignore it.
40
+ return []
41
+ if not isinstance(entries, list):
42
+ return []
43
+ providers: list[ProviderConfig] = []
44
+ for entry in entries:
45
+ if not isinstance(entry, dict):
46
+ continue
47
+ name = entry.get("name")
48
+ base_url = entry.get("base_url")
49
+ api_key_env = entry.get("api_key_env")
50
+ if not isinstance(name, str) or not isinstance(base_url, str):
51
+ continue
52
+ if not isinstance(api_key_env, str) or not os.environ.get(api_key_env):
53
+ continue
54
+ models_map = entry.get("models") or {}
55
+ providers.append(
56
+ ProviderConfig(
57
+ name=name,
58
+ base_url=base_url.rstrip("/"),
59
+ api_key=os.environ[api_key_env],
60
+ models_map=models_map if isinstance(models_map, dict) else {},
61
+ )
62
+ )
63
+ return providers
64
+
65
+
66
+ def rewrite_payload(payload: dict, provider: ProviderConfig) -> dict:
67
+ """Map the requested NVIDIA model onto the provider's own model id."""
68
+ copied = dict(payload)
69
+ model = copied.get("model", "")
70
+ if isinstance(model, str) and model in provider.models_map:
71
+ copied["model"] = provider.models_map[model]
72
+ elif provider.models_map:
73
+ copied["model"] = next(iter(provider.models_map.values()))
74
+ return copied
75
+
76
+
77
+ async def try_fallback(
78
+ client: httpx.AsyncClient,
79
+ payload: dict,
80
+ provider: ProviderConfig,
81
+ stream: bool = False,
82
+ ) -> httpx.Response | None:
83
+ """Attempt one fallback provider; None on any upstream failure."""
84
+ try:
85
+ resp = await client.post(
86
+ provider.base_url + "/chat/completions",
87
+ json=rewrite_payload(payload, provider),
88
+ headers={
89
+ "Authorization": f"Bearer {provider.api_key}",
90
+ "User-Agent": "openvidia/2.0",
91
+ },
92
+ timeout=_TIMEOUT_S,
93
+ )
94
+ except httpx.HTTPError:
95
+ return None
96
+ if not resp.is_success:
97
+ return None
98
+ return resp