velophex 0.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.
- velophex/__init__.py +38 -0
- velophex/_json.py +297 -0
- velophex/agents/__init__.py +130 -0
- velophex/agents/_errors.py +75 -0
- velophex/agents/_loop.py +436 -0
- velophex/agents/_tools.py +168 -0
- velophex/agents/_tracing.py +87 -0
- velophex/agents/testing.py +249 -0
- velophex/cli/__init__.py +5 -0
- velophex/cli/main.py +548 -0
- velophex/cli/publish.py +152 -0
- velophex/cli/sync.py +90 -0
- velophex/cli/templates.py +126 -0
- velophex/contracts/__init__.py +103 -0
- velophex/contracts/_model.py +26 -0
- velophex/contracts/agents.py +110 -0
- velophex/contracts/diagnostics.py +157 -0
- velophex/contracts/executor.py +1921 -0
- velophex/contracts/package.py +391 -0
- velophex/contracts/project.py +1008 -0
- velophex/contracts/runtimes.py +226 -0
- velophex/contracts/signin.py +53 -0
- velophex/contracts/validation.py +158 -0
- velophex/entrypoints.py +329 -0
- velophex/errors.py +44 -0
- velophex/packaging.py +465 -0
- velophex/py.typed +0 -0
- velophex/runtime/__init__.py +471 -0
- velophex/runtime/_session.py +192 -0
- velophex/runtime/client.py +957 -0
- velophex/runtime/models.py +326 -0
- velophex/signing.py +425 -0
- velophex/worker/__init__.py +27 -0
- velophex/worker/__main__.py +118 -0
- velophex/worker/framing.py +248 -0
- velophex/worker/loader.py +105 -0
- velophex/worker/outbox.py +147 -0
- velophex/worker/session.py +519 -0
- velophex-0.2.0.dist-info/METADATA +313 -0
- velophex-0.2.0.dist-info/RECORD +43 -0
- velophex-0.2.0.dist-info/WHEEL +4 -0
- velophex-0.2.0.dist-info/entry_points.txt +2 -0
- velophex-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Fakes for testing an agent without a model, an Orchestrator or a Robot.
|
|
2
|
+
|
|
3
|
+
* :class:`FakeChatServer` - a real local HTTP server in the shape of the LLM gateway's
|
|
4
|
+
``POST .../chat/completions`` (OpenAI chat completions, non-streaming), answering from a
|
|
5
|
+
script and recording every request;
|
|
6
|
+
* :class:`FakeTools` - tool calls answered by plain functions, approvals by a table;
|
|
7
|
+
* :class:`RecordingTracer` - keeps the spans a run produced.
|
|
8
|
+
|
|
9
|
+
>>> from velophex import agents
|
|
10
|
+
>>> from velophex.agents.testing import FakeChatServer, FakeTools, RecordingTracer
|
|
11
|
+
>>>
|
|
12
|
+
>>> with FakeChatServer(FakeChatServer.text('{"answer": "42"}')) as server:
|
|
13
|
+
... output = agents.run(definition, {}, chat=server.endpoint, tools=FakeTools(), tracer=RecordingTracer())
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import threading
|
|
20
|
+
import uuid
|
|
21
|
+
from collections.abc import Callable
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from velophex.agents._loop import ChatEndpoint
|
|
27
|
+
from velophex.agents._tools import ToolOutcome
|
|
28
|
+
from velophex.contracts.agents import AgentTool
|
|
29
|
+
|
|
30
|
+
__all__ = ["FakeChatServer", "FakeTools", "RecordingTracer", "ToolCall"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class FakeChatServer:
|
|
34
|
+
"""A scripted, OpenAI-shaped chat completions endpoint on ``127.0.0.1``.
|
|
35
|
+
|
|
36
|
+
Each reply is a message (from :meth:`text` or :meth:`tool_calls`) or a refusal (from
|
|
37
|
+
:meth:`refusal`); replies are used in order and a request past the script gets a 500.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
*replies: dict[str, Any] | tuple[int, dict[str, Any]],
|
|
43
|
+
routes: dict[str, Any] | None = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
self.replies: list[dict[str, Any] | tuple[int, dict[str, Any]]] = list(replies)
|
|
46
|
+
self.requests: list[dict[str, Any]] = []
|
|
47
|
+
self.headers: list[dict[str, str]] = []
|
|
48
|
+
#: Other Runtime API answers, by ``"<METHOD> <path under /api/v1/runtime>"``.
|
|
49
|
+
self.routes: dict[str, Any] = dict(routes or {})
|
|
50
|
+
#: Spans posted to ``traces/spans``, which is always answered.
|
|
51
|
+
self.spans: list[dict[str, Any]] = []
|
|
52
|
+
self._lock = threading.Lock()
|
|
53
|
+
self._server: ThreadingHTTPServer | None = None
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------- replies
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def text(content: str, *, prompt_tokens: int = 10, completion_tokens: int = 5) -> dict[str, Any]:
|
|
59
|
+
"""An assistant message with text and no tool call."""
|
|
60
|
+
return {
|
|
61
|
+
"message": {"role": "assistant", "content": content},
|
|
62
|
+
"finish_reason": "stop",
|
|
63
|
+
"usage": (prompt_tokens, completion_tokens),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def tool_calls(
|
|
68
|
+
*calls: tuple[str, dict[str, Any] | str], prompt_tokens: int = 10, completion_tokens: int = 5
|
|
69
|
+
) -> dict[str, Any]:
|
|
70
|
+
"""An assistant message calling tools; arguments given as a string are sent as is."""
|
|
71
|
+
return {
|
|
72
|
+
"message": {
|
|
73
|
+
"role": "assistant",
|
|
74
|
+
"content": None,
|
|
75
|
+
"tool_calls": [
|
|
76
|
+
{
|
|
77
|
+
"id": f"call_{uuid.uuid4().hex[:12]}",
|
|
78
|
+
"type": "function",
|
|
79
|
+
"function": {
|
|
80
|
+
"name": name,
|
|
81
|
+
"arguments": arguments if isinstance(arguments, str) else json.dumps(arguments),
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
for name, arguments in calls
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
"finish_reason": "tool_calls",
|
|
88
|
+
"usage": (prompt_tokens, completion_tokens),
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
@staticmethod
|
|
92
|
+
def refusal(status: int, code: str, **problem: Any) -> tuple[int, dict[str, Any]]:
|
|
93
|
+
"""A gateway refusal: RFC 9457 problem details with ``code``."""
|
|
94
|
+
return status, {"title": code, "status": status, "code": code, **problem}
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------- server
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def base_url(self) -> str:
|
|
100
|
+
"""The gateway base the client appends ``/chat/completions`` to."""
|
|
101
|
+
assert self._server is not None, "use the server as a context manager"
|
|
102
|
+
return f"http://127.0.0.1:{self._server.server_address[1]}/api/v1/runtime/ai"
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def runtime_url(self) -> str:
|
|
106
|
+
"""The Runtime API base, for a ``start`` context's ``orchestratorUrl``."""
|
|
107
|
+
return self.base_url.removesuffix("/ai")
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def endpoint(self) -> ChatEndpoint:
|
|
111
|
+
"""A :class:`ChatEndpoint` for :func:`velophex.agents.run`."""
|
|
112
|
+
return ChatEndpoint(self.base_url, "vpx_rt_test")
|
|
113
|
+
|
|
114
|
+
def __enter__(self) -> FakeChatServer:
|
|
115
|
+
fake = self
|
|
116
|
+
|
|
117
|
+
class Handler(BaseHTTPRequestHandler):
|
|
118
|
+
def do_GET(self) -> None:
|
|
119
|
+
self._route("GET", None)
|
|
120
|
+
|
|
121
|
+
def do_POST(self) -> None:
|
|
122
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
123
|
+
body = json.loads(self.rfile.read(length) or b"{}")
|
|
124
|
+
|
|
125
|
+
if not self.path.endswith("/chat/completions"):
|
|
126
|
+
self._route("POST", body)
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
with fake._lock:
|
|
130
|
+
fake.requests.append(body)
|
|
131
|
+
fake.headers.append({name.lower(): value for name, value in self.headers.items()})
|
|
132
|
+
reply = fake.replies.pop(0) if fake.replies else (500, {"title": "No scripted reply left."})
|
|
133
|
+
|
|
134
|
+
if isinstance(reply, tuple):
|
|
135
|
+
self._send(reply[0], reply[1], "application/problem+json")
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
prompt, completion = reply["usage"]
|
|
139
|
+
self._send(
|
|
140
|
+
200,
|
|
141
|
+
{
|
|
142
|
+
"id": f"chatcmpl-{uuid.uuid4().hex[:12]}",
|
|
143
|
+
"object": "chat.completion",
|
|
144
|
+
"created": 0,
|
|
145
|
+
"model": body.get("model"),
|
|
146
|
+
"choices": [{"index": 0, "message": reply["message"], "finish_reason": reply["finish_reason"]}],
|
|
147
|
+
"usage": {
|
|
148
|
+
"prompt_tokens": prompt,
|
|
149
|
+
"completion_tokens": completion,
|
|
150
|
+
"total_tokens": prompt + completion,
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
"application/json",
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def _route(self, method: str, body: Any) -> None:
|
|
157
|
+
path = self.path.split("?", 1)[0].removeprefix("/api/v1/runtime")
|
|
158
|
+
|
|
159
|
+
if method == "POST" and path == "/traces/spans":
|
|
160
|
+
with fake._lock:
|
|
161
|
+
fake.spans.extend(body.get("spans") or [])
|
|
162
|
+
|
|
163
|
+
self._send(202, {"status": "accepted"}, "application/json")
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
answer = fake.routes.get(f"{method} {path}")
|
|
167
|
+
|
|
168
|
+
if answer is None:
|
|
169
|
+
self._send(404, {"title": f"No route for {method} {path}."}, "application/problem+json")
|
|
170
|
+
else:
|
|
171
|
+
self._send(200, answer, "application/json")
|
|
172
|
+
|
|
173
|
+
def _send(self, status: int, payload: dict[str, Any], content_type: str) -> None:
|
|
174
|
+
data = json.dumps(payload).encode()
|
|
175
|
+
self.send_response(status)
|
|
176
|
+
self.send_header("Content-Type", content_type)
|
|
177
|
+
self.send_header("Content-Length", str(len(data)))
|
|
178
|
+
self.end_headers()
|
|
179
|
+
self.wfile.write(data)
|
|
180
|
+
|
|
181
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
182
|
+
pass
|
|
183
|
+
|
|
184
|
+
self._server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
185
|
+
threading.Thread(target=self._server.serve_forever, daemon=True).start()
|
|
186
|
+
return self
|
|
187
|
+
|
|
188
|
+
def __exit__(self, *exception: object) -> None:
|
|
189
|
+
if self._server is not None:
|
|
190
|
+
self._server.shutdown()
|
|
191
|
+
self._server.server_close()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass(frozen=True, slots=True)
|
|
195
|
+
class ToolCall:
|
|
196
|
+
"""One call :class:`FakeTools` received."""
|
|
197
|
+
|
|
198
|
+
name: str
|
|
199
|
+
arguments: dict[str, Any]
|
|
200
|
+
idempotency_key: str
|
|
201
|
+
timeout_seconds: int
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@dataclass
|
|
205
|
+
class FakeTools:
|
|
206
|
+
"""Tool calls answered by ``handlers[name](arguments)``; approvals by ``approvals[name]``.
|
|
207
|
+
|
|
208
|
+
A handler returns the tool's data, or a :class:`ToolOutcome` to answer a failure or an
|
|
209
|
+
unknown outcome; an exception it raises becomes a failed call. Approvals default to
|
|
210
|
+
``approved``.
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
handlers: dict[str, Callable[[dict[str, Any]], Any]] = field(default_factory=dict)
|
|
214
|
+
approvals: dict[str, str] = field(default_factory=dict)
|
|
215
|
+
calls: list[ToolCall] = field(default_factory=list)
|
|
216
|
+
approval_requests: list[ToolCall] = field(default_factory=list)
|
|
217
|
+
|
|
218
|
+
def call(
|
|
219
|
+
self, tool: AgentTool, arguments: dict[str, Any], *, idempotency_key: str, timeout_seconds: int
|
|
220
|
+
) -> ToolOutcome:
|
|
221
|
+
"""Answer from the handler."""
|
|
222
|
+
self.calls.append(ToolCall(tool.name, arguments, idempotency_key, timeout_seconds))
|
|
223
|
+
handler = self.handlers.get(tool.name)
|
|
224
|
+
|
|
225
|
+
if handler is None:
|
|
226
|
+
return ToolOutcome(ok=False, error=f"FakeTools has no handler for '{tool.name}'.")
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
result = handler(arguments)
|
|
230
|
+
except Exception as error:
|
|
231
|
+
return ToolOutcome(ok=False, error=str(error))
|
|
232
|
+
|
|
233
|
+
return result if isinstance(result, ToolOutcome) else ToolOutcome(ok=True, data=result)
|
|
234
|
+
|
|
235
|
+
def approve(self, tool: AgentTool, arguments: dict[str, Any], *, idempotency_key: str, timeout_seconds: int) -> str:
|
|
236
|
+
"""Answer from the approval table."""
|
|
237
|
+
self.approval_requests.append(ToolCall(tool.name, arguments, idempotency_key, timeout_seconds))
|
|
238
|
+
return self.approvals.get(tool.name, "approved")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@dataclass
|
|
242
|
+
class RecordingTracer:
|
|
243
|
+
"""Keeps every span in :attr:`spans`."""
|
|
244
|
+
|
|
245
|
+
spans: list[dict[str, Any]] = field(default_factory=list)
|
|
246
|
+
|
|
247
|
+
def record(self, span: dict[str, Any]) -> None:
|
|
248
|
+
"""Keep the span."""
|
|
249
|
+
self.spans.append(span)
|