mandala-computer 0.1.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,279 @@
1
+ """Python SDK for Mandala Computer — cloud desktops for AI agents.
2
+
3
+ from mandala_computer import Client
4
+
5
+ client = Client() # MANDALA_API_KEY
6
+ with client.computers.ephemeral(template="base") as c:
7
+ c.wait_for_guest()
8
+ c.open("https://example.com") # on the screen, not as root
9
+ png = c.screenshot()
10
+ c.click(640, 400)
11
+ c.type("hello")
12
+
13
+ ``AsyncClient`` mirrors it method for method:
14
+
15
+ from mandala_computer import AsyncClient
16
+
17
+ async with AsyncClient() as client:
18
+ async with client.computers.ephemeral(template="base") as c:
19
+ await c.wait_for_guest()
20
+ png = await c.screenshot()
21
+
22
+ This binds only to the platform's curated ``/api/v1`` surface, never to the
23
+ hypervisor daemon's own routes — see the README for why that boundary exists.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import httpx
29
+
30
+ from ._agent import (
31
+ AgentDone,
32
+ AgentEvent,
33
+ AgentFailed,
34
+ AgentResult,
35
+ AgentStep,
36
+ AgentStepEvent,
37
+ AgentText,
38
+ AgentUsage,
39
+ )
40
+ from ._async_computer import AsyncBackgroundCommand, AsyncComputer
41
+ from ._async_resources import (
42
+ AsyncBuilds,
43
+ AsyncComputers,
44
+ AsyncMoves,
45
+ AsyncSizes,
46
+ AsyncSnapshots,
47
+ AsyncTemplates,
48
+ AsyncUsage,
49
+ AsyncWebhooks,
50
+ )
51
+ from ._client import DEFAULT_BASE_URL, DEFAULT_TIMEOUT, AsyncTransport, Transport
52
+ from ._computer import SCREEN_HEIGHT, SCREEN_WIDTH, BackgroundCommand, Computer
53
+ from ._events import (
54
+ CHANNEL_EVENT_TYPES,
55
+ DESKTOP_EVENT_TYPES,
56
+ GUEST_EVENT_TYPES,
57
+ STREAM_FRAME_TYPES,
58
+ WATCH_EVENT_TYPE,
59
+ AsyncEventStream,
60
+ ComputerEvent,
61
+ EventStream,
62
+ Hello,
63
+ WatchedTree,
64
+ )
65
+ from ._exceptions import (
66
+ APIError,
67
+ AuthenticationError,
68
+ ConflictError,
69
+ ConnectionError,
70
+ ConnectionInterruptedError,
71
+ FileTooLargeError,
72
+ GatewayTimeoutError,
73
+ MandalaError,
74
+ MoveRequiredError,
75
+ NotFoundError,
76
+ OriginResponseError,
77
+ OriginTLSError,
78
+ OriginUnreachableError,
79
+ PermissionDeniedError,
80
+ PlanLimitError,
81
+ RangeNotSatisfiableError,
82
+ RateLimitError,
83
+ TimeoutError,
84
+ UnavailableError,
85
+ is_transient,
86
+ )
87
+ from ._models import (
88
+ BuildProgress,
89
+ BuildStep,
90
+ ComputerUsage,
91
+ ExecResult,
92
+ ExecStatus,
93
+ FilePart,
94
+ Listing,
95
+ Move,
96
+ PublishedTemplate,
97
+ Retention,
98
+ RetiredTemplates,
99
+ Size,
100
+ Snapshot,
101
+ SnapshotHoldings,
102
+ Template,
103
+ TemplateBuild,
104
+ TemplateCheck,
105
+ UsagePeriod,
106
+ UsageReport,
107
+ UsageTotals,
108
+ VncConnect,
109
+ Webhook,
110
+ WebhookCreated,
111
+ WebhookDelivery,
112
+ Window,
113
+ WindowResult,
114
+ )
115
+ from ._resources import Builds, Computers, Moves, Sizes, Snapshots, Templates, Usage, Webhooks
116
+ from ._webhooks import REPLAY_WINDOW_S, verify
117
+
118
+ __version__ = "0.1.0"
119
+
120
+ __all__ = [
121
+ "CHANNEL_EVENT_TYPES",
122
+ "DEFAULT_BASE_URL",
123
+ "DESKTOP_EVENT_TYPES",
124
+ "GUEST_EVENT_TYPES",
125
+ "REPLAY_WINDOW_S",
126
+ "SCREEN_HEIGHT",
127
+ "SCREEN_WIDTH",
128
+ "STREAM_FRAME_TYPES",
129
+ "WATCH_EVENT_TYPE",
130
+ "APIError",
131
+ "AgentDone",
132
+ "AgentEvent",
133
+ "AgentFailed",
134
+ "AgentResult",
135
+ "AgentStep",
136
+ "AgentStepEvent",
137
+ "AgentText",
138
+ "AgentUsage",
139
+ "AsyncBackgroundCommand",
140
+ "AsyncClient",
141
+ "AsyncComputer",
142
+ "AsyncEventStream",
143
+ "AuthenticationError",
144
+ "BackgroundCommand",
145
+ "BuildProgress",
146
+ "BuildStep",
147
+ "Client",
148
+ "Computer",
149
+ "ComputerEvent",
150
+ "ComputerUsage",
151
+ "ConflictError",
152
+ "ConnectionError",
153
+ "ConnectionInterruptedError",
154
+ "EventStream",
155
+ "ExecResult",
156
+ "ExecStatus",
157
+ "FilePart",
158
+ "FileTooLargeError",
159
+ "GatewayTimeoutError",
160
+ "Hello",
161
+ "Listing",
162
+ "MandalaError",
163
+ "Move",
164
+ "MoveRequiredError",
165
+ "NotFoundError",
166
+ "OriginResponseError",
167
+ "OriginTLSError",
168
+ "OriginUnreachableError",
169
+ "PermissionDeniedError",
170
+ "PlanLimitError",
171
+ "PublishedTemplate",
172
+ "RangeNotSatisfiableError",
173
+ "RateLimitError",
174
+ "Retention",
175
+ "RetiredTemplates",
176
+ "Size",
177
+ "Snapshot",
178
+ "SnapshotHoldings",
179
+ "Template",
180
+ "TemplateBuild",
181
+ "TemplateCheck",
182
+ "TimeoutError",
183
+ "UnavailableError",
184
+ "UsagePeriod",
185
+ "UsageReport",
186
+ "UsageTotals",
187
+ "VncConnect",
188
+ "WatchedTree",
189
+ "Webhook",
190
+ "WebhookCreated",
191
+ "WebhookDelivery",
192
+ "Window",
193
+ "WindowResult",
194
+ "__version__",
195
+ "is_transient",
196
+ "verify",
197
+ ]
198
+
199
+
200
+ class Client:
201
+ """Entry point to the Mandala Computer API.
202
+
203
+ :param api_key: defaults to ``MANDALA_API_KEY``.
204
+ :param base_url: defaults to ``MANDALA_BASE_URL``, then the public API.
205
+ """
206
+
207
+ def __init__(
208
+ self,
209
+ api_key: str | None = None,
210
+ *,
211
+ base_url: str | None = None,
212
+ timeout: float = DEFAULT_TIMEOUT,
213
+ http_client: httpx.Client | None = None,
214
+ ) -> None:
215
+ self._t = Transport(api_key, base_url=base_url, timeout=timeout, client=http_client)
216
+ self.builds = Builds(self._t)
217
+ self.computers = Computers(self._t)
218
+ self.moves = Moves(self._t)
219
+ self.snapshots = Snapshots(self._t)
220
+ self.templates = Templates(self._t)
221
+ self.sizes = Sizes(self._t)
222
+ self.usage = Usage(self._t)
223
+ self.webhooks = Webhooks(self._t)
224
+
225
+ @property
226
+ def base_url(self) -> str:
227
+ return self._t.base_url
228
+
229
+ def close(self) -> None:
230
+ self._t.close()
231
+
232
+ # typing.Self is 3.11+; the floor here is 3.10, so name the class instead.
233
+ def __enter__(self) -> Client: # noqa: PYI034
234
+ return self
235
+
236
+ def __exit__(self, *exc: object) -> None:
237
+ self.close()
238
+
239
+
240
+ class AsyncClient:
241
+ """Entry point to the Mandala Computer API, driven with ``await``.
242
+
243
+ Same arguments and behaviour as :class:`Client`; every method that performs
244
+ IO is a coroutine.
245
+
246
+ :param api_key: defaults to ``MANDALA_API_KEY``.
247
+ :param base_url: defaults to ``MANDALA_BASE_URL``, then the public API.
248
+ """
249
+
250
+ def __init__(
251
+ self,
252
+ api_key: str | None = None,
253
+ *,
254
+ base_url: str | None = None,
255
+ timeout: float = DEFAULT_TIMEOUT,
256
+ http_client: httpx.AsyncClient | None = None,
257
+ ) -> None:
258
+ self._t = AsyncTransport(api_key, base_url=base_url, timeout=timeout, client=http_client)
259
+ self.builds = AsyncBuilds(self._t)
260
+ self.computers = AsyncComputers(self._t)
261
+ self.moves = AsyncMoves(self._t)
262
+ self.snapshots = AsyncSnapshots(self._t)
263
+ self.templates = AsyncTemplates(self._t)
264
+ self.sizes = AsyncSizes(self._t)
265
+ self.usage = AsyncUsage(self._t)
266
+ self.webhooks = AsyncWebhooks(self._t)
267
+
268
+ @property
269
+ def base_url(self) -> str:
270
+ return self._t.base_url
271
+
272
+ async def aclose(self) -> None:
273
+ await self._t.aclose()
274
+
275
+ async def __aenter__(self) -> AsyncClient: # noqa: PYI034
276
+ return self
277
+
278
+ async def __aexit__(self, *exc: object) -> None:
279
+ await self.aclose()
@@ -0,0 +1,248 @@
1
+ """The platform's own agent loop, as types.
2
+
3
+ ``POST computers/:id/agent`` is not a call to a hypervisor — it is many of them,
4
+ interleaved with calls to a model API, running for minutes. So it answers with a
5
+ stream of steps rather than a result, and this file is the shape of that stream.
6
+
7
+ It runs on **your** Anthropic key, which the platform never stores: pass it as
8
+ ``model_key`` and it travels on that one request as ``X-Model-Key``. Every step
9
+ is a model call plus a screenshot billed to that key, which is why ``max_steps``
10
+ bounds spending as much as it bounds the loop.
11
+
12
+ Everything here is built by :func:`to_agent_event` out of whatever a frame
13
+ carried, and never by asserting a shape. A frame this SDK does not model is
14
+ skipped rather than raised on: the platform is free to add event types, and a
15
+ client that fell over on the first unrecognised one would turn a
16
+ forward-compatible addition into an outage.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import math
22
+ from collections.abc import Mapping
23
+ from dataclasses import dataclass, field
24
+ from typing import Any
25
+
26
+ __all__ = [
27
+ "AgentDone",
28
+ "AgentEvent",
29
+ "AgentFailed",
30
+ "AgentResult",
31
+ "AgentStep",
32
+ "AgentStepEvent",
33
+ "AgentText",
34
+ "AgentUsage",
35
+ ]
36
+
37
+
38
+ def _num(value: Any) -> int:
39
+ """A count off the wire, or ``0``.
40
+
41
+ Never raises, and the word is load-bearing: a malformed number in one field
42
+ must not lose the run's result along with it — a step count that arrived as
43
+ ``null`` is worth reporting as zero, and is not worth discarding the model's
44
+ answer over.
45
+
46
+ ``OverflowError`` is caught alongside the two obvious ones because JSON has
47
+ no integer ceiling and Python's ``int`` has none either, so a 400-digit
48
+ literal parses fine and only fails on the way to a ``float``. It is a
49
+ malformed number like any other here.
50
+ """
51
+ try:
52
+ n = float(value)
53
+ except (OverflowError, TypeError, ValueError):
54
+ return 0
55
+ # NaN and the infinities parse as floats and are not counts. int(nan) raises
56
+ # and int(inf) raises too, so this is the guard as much as the filter.
57
+ return int(n) if math.isfinite(n) else 0
58
+
59
+
60
+ def _text(value: Any) -> str:
61
+ return "" if value is None else str(value)
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class AgentStep:
66
+ """One action the loop took."""
67
+
68
+ #: Which step this was, counting from 1.
69
+ n: int
70
+ #: The tool the model reached for, e.g. ``"computer"`` or ``"bash"``.
71
+ tool: str = ""
72
+ #: The action within it, e.g. ``"left_click"``. Empty for bash.
73
+ action: str = ""
74
+ #: What the platform did with it, in one line.
75
+ detail: str = ""
76
+ #: Set when the action was refused. The loop continues and the model adapts,
77
+ #: so this is a step that did not work rather than a run that failed.
78
+ error: str = ""
79
+ raw: Mapping[str, Any] = field(default_factory=dict, repr=False)
80
+
81
+ @classmethod
82
+ def from_api(cls, d: Any, fallback_n: int) -> AgentStep:
83
+ """This step, with ``fallback_n`` standing in for a number it did not give.
84
+
85
+ "Did not give" is any unusable number, not only a missing key: steps
86
+ count from 1, so a ``null``, a string, or a zero all mean the same thing
87
+ — nothing to number this step by — and reading one as ``0`` would put
88
+ the "0." in a caller's progress line that the fallback exists to
89
+ prevent.
90
+ """
91
+ r = d if isinstance(d, Mapping) else {}
92
+ n = _num(r.get("n"))
93
+ return cls(
94
+ n=n if n > 0 else fallback_n,
95
+ tool=_text(r.get("tool")),
96
+ action=_text(r.get("action")),
97
+ detail=_text(r.get("detail")),
98
+ error=_text(r.get("error")),
99
+ raw=dict(r),
100
+ )
101
+
102
+
103
+ @dataclass(frozen=True)
104
+ class AgentUsage:
105
+ """What a run cost on your key.
106
+
107
+ :attr:`input_tokens` includes the two cache halves, which are most of a long
108
+ run — the rolling breakpoint means step ten's prompt is almost entirely
109
+ cache reads. They are broken out as well, because they are priced
110
+ differently and reconciling against an Anthropic bill needs to see them.
111
+ """
112
+
113
+ input_tokens: int = 0
114
+ output_tokens: int = 0
115
+ cache_read_tokens: int = 0
116
+ cache_write_tokens: int = 0
117
+
118
+ @classmethod
119
+ def from_api(cls, d: Any) -> AgentUsage:
120
+ r = d if isinstance(d, Mapping) else {}
121
+ return cls(
122
+ input_tokens=_num(r.get("input_tokens")),
123
+ output_tokens=_num(r.get("output_tokens")),
124
+ cache_read_tokens=_num(r.get("cache_read_tokens")),
125
+ cache_write_tokens=_num(r.get("cache_write_tokens")),
126
+ )
127
+
128
+
129
+ @dataclass(frozen=True)
130
+ class AgentResult:
131
+ """How a run ended, and what it did."""
132
+
133
+ #: How many steps it took.
134
+ steps: int = 0
135
+ #: Why it ended: ``end_turn``, ``max_steps``, ``rate_limited``, ``refusal``
136
+ #: — or something added since, which is why this is a string and not an
137
+ #: enum.
138
+ stop: str = ""
139
+ #: The model's closing text — its answer, or why it could not get there.
140
+ text: str = ""
141
+ usage: AgentUsage = field(default_factory=AgentUsage)
142
+ raw: Mapping[str, Any] = field(default_factory=dict, repr=False)
143
+
144
+ @property
145
+ def finished(self) -> bool:
146
+ """True only for ``end_turn`` — the model deciding it was done.
147
+
148
+ The check most callers actually want, and the reason it is here rather
149
+ than left to everyone to write: treating every ending as success reports
150
+ a run that hit its step cap as one that completed the task.
151
+ """
152
+ return self.stop == "end_turn"
153
+
154
+ @classmethod
155
+ def from_api(cls, d: Any) -> AgentResult:
156
+ r = d if isinstance(d, Mapping) else {}
157
+ return cls(
158
+ steps=_num(r.get("steps")),
159
+ stop=_text(r.get("stop")) or "unknown",
160
+ text=_text(r.get("text")),
161
+ usage=AgentUsage.from_api(r.get("usage")),
162
+ raw=dict(r),
163
+ )
164
+
165
+
166
+ @dataclass(frozen=True)
167
+ class AgentStepEvent:
168
+ """The loop did something. Tell your user about it."""
169
+
170
+ step: AgentStep
171
+
172
+
173
+ @dataclass(frozen=True)
174
+ class AgentText:
175
+ """The model said something on its way through."""
176
+
177
+ text: str
178
+
179
+
180
+ @dataclass(frozen=True)
181
+ class AgentDone:
182
+ """The run ended, however it ended. Carries the result."""
183
+
184
+ result: AgentResult
185
+
186
+
187
+ @dataclass(frozen=True)
188
+ class AgentFailed:
189
+ """The run went wrong, mid-stream.
190
+
191
+ Distinct from a run that ended unfinished: ``max_steps`` and
192
+ ``rate_limited`` arrive as an :class:`AgentDone` carrying a result, because
193
+ the steps already taken are real and what they did to the desktop stands.
194
+ This is the platform saying the run itself could not continue.
195
+ """
196
+
197
+ error: str
198
+ #: The HTTP status the failure would have had, or ``0`` where the platform
199
+ #: did not name one. Carried so a 401 arriving mid-run can be raised as the
200
+ #: same :class:`~mandala_computer.AuthenticationError` a 401 anywhere else
201
+ #: raises, rather than as something a caller's handler cannot classify.
202
+ status: int = 0
203
+ #: What the run had already spent on your key when it went wrong. A failure
204
+ #: at step eight has been billed for eight steps whether or not anything is
205
+ #: told about it, and this is the only place that number is ever reported —
206
+ #: the platform meters nothing on your model key, so there is no invoice to
207
+ #: reconcile it against later.
208
+ usage: AgentUsage = field(default_factory=AgentUsage)
209
+ #: What the run had already done to the desktop. Those actions stand: the
210
+ #: failure stopped the loop, it did not undo the clicks. Left empty where
211
+ #: the platform could not say — its own last-resort handler reports the
212
+ #: error and the status alone — so this being empty means "not reported"
213
+ #: rather than "nothing happened".
214
+ steps: tuple[AgentStep, ...] = ()
215
+
216
+
217
+ #: One event out of :meth:`~mandala_computer.Computer.agent_stream`. Match on it
218
+ #: with ``isinstance`` — the four are a closed set as far as this SDK models the
219
+ #: stream, and anything else the platform sends is skipped before it gets here.
220
+ AgentEvent = AgentStepEvent | AgentText | AgentDone | AgentFailed
221
+
222
+
223
+ def to_agent_event(event: str, data: Any, step_count: int) -> AgentEvent | None:
224
+ """One frame as an event, or ``None`` for a frame this SDK does not model."""
225
+ if event == "step":
226
+ return AgentStepEvent(AgentStep.from_api(data, step_count + 1))
227
+ if event == "text":
228
+ text = _text(data.get("text") if isinstance(data, Mapping) else data)
229
+ # A frame that said nothing is skipped like any other frame this SDK
230
+ # cannot read. The README's loop prints what it is handed, and an empty
231
+ # AgentText is a blank line in a caller's output standing for a payload
232
+ # whose shape we did not recognise.
233
+ return AgentText(text) if text else None
234
+ if event == "done":
235
+ return AgentDone(AgentResult.from_api(data))
236
+ if event == "error":
237
+ r = data if isinstance(data, Mapping) else {}
238
+ taken = r.get("steps")
239
+ return AgentFailed(
240
+ _text(r.get("error")) or "the run failed",
241
+ _num(r.get("status")),
242
+ AgentUsage.from_api(r.get("usage")),
243
+ tuple(
244
+ AgentStep.from_api(step, i + 1)
245
+ for i, step in enumerate(taken if isinstance(taken, list) else ())
246
+ ),
247
+ )
248
+ return None