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
|
@@ -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
|