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.
Files changed (43) hide show
  1. velophex/__init__.py +38 -0
  2. velophex/_json.py +297 -0
  3. velophex/agents/__init__.py +130 -0
  4. velophex/agents/_errors.py +75 -0
  5. velophex/agents/_loop.py +436 -0
  6. velophex/agents/_tools.py +168 -0
  7. velophex/agents/_tracing.py +87 -0
  8. velophex/agents/testing.py +249 -0
  9. velophex/cli/__init__.py +5 -0
  10. velophex/cli/main.py +548 -0
  11. velophex/cli/publish.py +152 -0
  12. velophex/cli/sync.py +90 -0
  13. velophex/cli/templates.py +126 -0
  14. velophex/contracts/__init__.py +103 -0
  15. velophex/contracts/_model.py +26 -0
  16. velophex/contracts/agents.py +110 -0
  17. velophex/contracts/diagnostics.py +157 -0
  18. velophex/contracts/executor.py +1921 -0
  19. velophex/contracts/package.py +391 -0
  20. velophex/contracts/project.py +1008 -0
  21. velophex/contracts/runtimes.py +226 -0
  22. velophex/contracts/signin.py +53 -0
  23. velophex/contracts/validation.py +158 -0
  24. velophex/entrypoints.py +329 -0
  25. velophex/errors.py +44 -0
  26. velophex/packaging.py +465 -0
  27. velophex/py.typed +0 -0
  28. velophex/runtime/__init__.py +471 -0
  29. velophex/runtime/_session.py +192 -0
  30. velophex/runtime/client.py +957 -0
  31. velophex/runtime/models.py +326 -0
  32. velophex/signing.py +425 -0
  33. velophex/worker/__init__.py +27 -0
  34. velophex/worker/__main__.py +118 -0
  35. velophex/worker/framing.py +248 -0
  36. velophex/worker/loader.py +105 -0
  37. velophex/worker/outbox.py +147 -0
  38. velophex/worker/session.py +519 -0
  39. velophex-0.2.0.dist-info/METADATA +313 -0
  40. velophex-0.2.0.dist-info/RECORD +43 -0
  41. velophex-0.2.0.dist-info/WHEEL +4 -0
  42. velophex-0.2.0.dist-info/entry_points.txt +2 -0
  43. 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)
@@ -0,0 +1,5 @@
1
+ """The ``velophex`` command line: ``init``, ``run``, ``pack`` and ``publish``."""
2
+
3
+ from velophex.cli.main import app, main
4
+
5
+ __all__ = ["app", "main"]