sai-sdk 0.1.0a1__tar.gz

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,5 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ .pytest_cache/
5
+ dist/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Simular, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,161 @@
1
+ Metadata-Version: 2.5
2
+ Name: sai-sdk
3
+ Version: 0.1.0a1
4
+ Summary: Python SDK for the Sai API: delegate desktop work to Sai, or drive a cloud computer directly.
5
+ Project-URL: Documentation, https://platform.simular.ai/documentation
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Typing :: Typed
12
+ Requires-Python: >=3.9
13
+ Requires-Dist: httpx<1,>=0.25
14
+ Description-Content-Type: text/markdown
15
+
16
+ # sai-sdk (Python)
17
+
18
+ Python SDK for the [Sai API](https://platform.simular.ai/documentation). Hand desktop work to Sai on a
19
+ computer (a Sai cloud computer, or your own PC with the Sai app) and get the answer back, or drive a cloud
20
+ computer yourself.
21
+
22
+ Status: **alpha (0.1)**. The surface may still change before 1.0.
23
+
24
+ | Layer | What it is | Status |
25
+ | ---------------------- | ------------------------------------------------- | --------------- |
26
+ | `sai.sessions` | A conversation with Sai on one computer | available |
27
+ | `sai.control_sessions` | Drive a cloud computer directly with Simulang | research preview |
28
+ | `sai.runs` | Typed tasks: inputs, output schema, evidence | not yet |
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ pip install sai-sdk # import name: sai
34
+ export SAI_API_KEY=sapi_... # create one at https://platform.simular.ai
35
+ ```
36
+
37
+ Python 3.9+. One dependency, `httpx`.
38
+
39
+ ## Give Sai a task
40
+
41
+ ```python
42
+ from sai import Sai
43
+
44
+ sai = Sai()
45
+ pc = sai.machines.list()[0]
46
+
47
+ session = sai.sessions.create(machine=pc) # a fresh conversation
48
+ result = session.run("Open Notepad, type hello, save it to the desktop as hello.txt")
49
+
50
+ print(result.status) # idle | needs_approval | error
51
+ print(result.text) # Sai's answer
52
+ print(result.usage) # tokens and cost for this task
53
+ ```
54
+
55
+ `machine` can be left out when the account has one computer. Further `session.run(...)` calls continue
56
+ the same conversation; `sai.sessions.create()` starts a new one with no memory of the old.
57
+
58
+ ### Watch it work
59
+
60
+ ```python
61
+ turn = session.send("Find last month's invoices in Downloads and total them")
62
+ for ev in turn.events():
63
+ if ev.kind == "step":
64
+ print(" ", ev.text)
65
+ elif ev.kind == "approval":
66
+ print("Sai asks:", ev.approval.title, ev.approval.command)
67
+ turn.respond(ev.approval, "yes") # or "no", or "task" for the rest of the task
68
+ result = turn.wait()
69
+ ```
70
+
71
+ `ev.kind` is one of `step`, `message`, `approval`, `tool_error`, `error`, `finished`.
72
+ `turn.events(raw=True)` adds the wire frames (reasoning, tool calls) as `other`.
73
+
74
+ ### Approvals
75
+
76
+ Sai asks before risky actions. `run()` / `wait()` return with `status == "needs_approval"` unless you pass
77
+ a handler:
78
+
79
+ ```python
80
+ def decide(approval):
81
+ if approval.is_link_only:
82
+ print("Approve in a browser:", approval.url) # a human answers; keep waiting
83
+ return None
84
+ if approval.type == "choice":
85
+ return ("yes", [[q["options"][0]["value"]] for q in approval.questions])
86
+ return "yes" if approval.command and "del " not in approval.command else "no"
87
+
88
+ result = session.run("Clean up the temp folder", on_approval=decide)
89
+ ```
90
+
91
+ ### Files, models, stopping
92
+
93
+ ```python
94
+ report = sai.files.upload("report.pdf")
95
+ session.run("Summarise the attached report", attachments=[report])
96
+
97
+ [m.id for m in sai.models.list() if m.allowed]
98
+ session.run("...", model="anthropic/claude-opus-5-5") # sticks to the session
99
+
100
+ turn = session.send("...")
101
+ turn.abort()
102
+ ```
103
+
104
+ ## Drive a computer yourself
105
+
106
+ Control sessions run [Simulang](https://platform.simular.ai/documentation/concepts/direct-control)
107
+ blocks on a cloud computer. No agent, no model cost. While one is open, Sai's own tasks on that
108
+ computer wait, so always close it (a `with` block does).
109
+
110
+ ```python
111
+ with sai.control_sessions.open(machine=pc) as cs:
112
+ print(cs.shell("hostname")["stdout"])
113
+
114
+ r = cs.exec("""
115
+ var page = await machine.browser.newtab('https://news.ycombinator.com')
116
+ var snap = await page.snapshot()
117
+ snap.snapshot.grep('points')
118
+ """)
119
+ print(r.result)
120
+ open("hn.png", "wb").write(r.screenshot_png) # a page or app snapshot carries a screenshot
121
+ ```
122
+
123
+ `cs.screenshot()` grabs the whole screen. It needs the computer's screen-capture service; where that is
124
+ off it raises `BlockError`, and a page or app snapshot is the way to get an image.
125
+
126
+ `exec()` returns an `ExecResult`; a block that threw is still a result, so check `r.error`
127
+ (`exception`, `timeout`, `interrupted`). Use `var`, not `let`/`const`, for values the next block needs.
128
+
129
+ ## Errors
130
+
131
+ Every non-2xx response raises a `SaiError` subclass with `status`, `code`, `message` and `retry_after`:
132
+
133
+ | Exception | When |
134
+ | ------------------------- | --------------------------------------------------------------------- |
135
+ | `AuthenticationError` | 401: the key is missing, unknown or revoked |
136
+ | `PermissionDeniedError` | 403: plan or ownership (`model_not_allowed_for_plan`, ...) |
137
+ | `BadRequestError` | 400: e.g. several computers and no `machine=` |
138
+ | `ConflictError` | 409: `machine_busy`, approval no longer pending, ... |
139
+ | `LinkOnlyApprovalError` | the approval must be answered in a browser: `e.approval_url` |
140
+ | `GoneError` | 410: the control session lost its computer; open a new one |
141
+ | `RateLimitError` | 429: a rate limit, or the free plan's daily budget |
142
+ | `ServiceUnavailableError` | 503: the computer did not come online; honor `retry_after` |
143
+
144
+ A task that ran out of credit ends with `result.status == "error"` and `result.error_code` set to
145
+ `insufficient_credits` or `free_computer_time_exhausted`: stop retrying for today.
146
+
147
+ ## Configuration
148
+
149
+ | Argument | Env | Default |
150
+ | ------------- | ------------- | ------------------------ |
151
+ | `api_key` | `SAI_API_KEY` | required |
152
+ | `base_url` | `SAI_API_URL` | `https://api.simular.ai` |
153
+ | `timeout` | | 60 s per request |
154
+
155
+ ## Development
156
+
157
+ ```bash
158
+ cd python
159
+ uv sync
160
+ uv run pytest
161
+ ```
@@ -0,0 +1,146 @@
1
+ # sai-sdk (Python)
2
+
3
+ Python SDK for the [Sai API](https://platform.simular.ai/documentation). Hand desktop work to Sai on a
4
+ computer (a Sai cloud computer, or your own PC with the Sai app) and get the answer back, or drive a cloud
5
+ computer yourself.
6
+
7
+ Status: **alpha (0.1)**. The surface may still change before 1.0.
8
+
9
+ | Layer | What it is | Status |
10
+ | ---------------------- | ------------------------------------------------- | --------------- |
11
+ | `sai.sessions` | A conversation with Sai on one computer | available |
12
+ | `sai.control_sessions` | Drive a cloud computer directly with Simulang | research preview |
13
+ | `sai.runs` | Typed tasks: inputs, output schema, evidence | not yet |
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pip install sai-sdk # import name: sai
19
+ export SAI_API_KEY=sapi_... # create one at https://platform.simular.ai
20
+ ```
21
+
22
+ Python 3.9+. One dependency, `httpx`.
23
+
24
+ ## Give Sai a task
25
+
26
+ ```python
27
+ from sai import Sai
28
+
29
+ sai = Sai()
30
+ pc = sai.machines.list()[0]
31
+
32
+ session = sai.sessions.create(machine=pc) # a fresh conversation
33
+ result = session.run("Open Notepad, type hello, save it to the desktop as hello.txt")
34
+
35
+ print(result.status) # idle | needs_approval | error
36
+ print(result.text) # Sai's answer
37
+ print(result.usage) # tokens and cost for this task
38
+ ```
39
+
40
+ `machine` can be left out when the account has one computer. Further `session.run(...)` calls continue
41
+ the same conversation; `sai.sessions.create()` starts a new one with no memory of the old.
42
+
43
+ ### Watch it work
44
+
45
+ ```python
46
+ turn = session.send("Find last month's invoices in Downloads and total them")
47
+ for ev in turn.events():
48
+ if ev.kind == "step":
49
+ print(" ", ev.text)
50
+ elif ev.kind == "approval":
51
+ print("Sai asks:", ev.approval.title, ev.approval.command)
52
+ turn.respond(ev.approval, "yes") # or "no", or "task" for the rest of the task
53
+ result = turn.wait()
54
+ ```
55
+
56
+ `ev.kind` is one of `step`, `message`, `approval`, `tool_error`, `error`, `finished`.
57
+ `turn.events(raw=True)` adds the wire frames (reasoning, tool calls) as `other`.
58
+
59
+ ### Approvals
60
+
61
+ Sai asks before risky actions. `run()` / `wait()` return with `status == "needs_approval"` unless you pass
62
+ a handler:
63
+
64
+ ```python
65
+ def decide(approval):
66
+ if approval.is_link_only:
67
+ print("Approve in a browser:", approval.url) # a human answers; keep waiting
68
+ return None
69
+ if approval.type == "choice":
70
+ return ("yes", [[q["options"][0]["value"]] for q in approval.questions])
71
+ return "yes" if approval.command and "del " not in approval.command else "no"
72
+
73
+ result = session.run("Clean up the temp folder", on_approval=decide)
74
+ ```
75
+
76
+ ### Files, models, stopping
77
+
78
+ ```python
79
+ report = sai.files.upload("report.pdf")
80
+ session.run("Summarise the attached report", attachments=[report])
81
+
82
+ [m.id for m in sai.models.list() if m.allowed]
83
+ session.run("...", model="anthropic/claude-opus-5-5") # sticks to the session
84
+
85
+ turn = session.send("...")
86
+ turn.abort()
87
+ ```
88
+
89
+ ## Drive a computer yourself
90
+
91
+ Control sessions run [Simulang](https://platform.simular.ai/documentation/concepts/direct-control)
92
+ blocks on a cloud computer. No agent, no model cost. While one is open, Sai's own tasks on that
93
+ computer wait, so always close it (a `with` block does).
94
+
95
+ ```python
96
+ with sai.control_sessions.open(machine=pc) as cs:
97
+ print(cs.shell("hostname")["stdout"])
98
+
99
+ r = cs.exec("""
100
+ var page = await machine.browser.newtab('https://news.ycombinator.com')
101
+ var snap = await page.snapshot()
102
+ snap.snapshot.grep('points')
103
+ """)
104
+ print(r.result)
105
+ open("hn.png", "wb").write(r.screenshot_png) # a page or app snapshot carries a screenshot
106
+ ```
107
+
108
+ `cs.screenshot()` grabs the whole screen. It needs the computer's screen-capture service; where that is
109
+ off it raises `BlockError`, and a page or app snapshot is the way to get an image.
110
+
111
+ `exec()` returns an `ExecResult`; a block that threw is still a result, so check `r.error`
112
+ (`exception`, `timeout`, `interrupted`). Use `var`, not `let`/`const`, for values the next block needs.
113
+
114
+ ## Errors
115
+
116
+ Every non-2xx response raises a `SaiError` subclass with `status`, `code`, `message` and `retry_after`:
117
+
118
+ | Exception | When |
119
+ | ------------------------- | --------------------------------------------------------------------- |
120
+ | `AuthenticationError` | 401: the key is missing, unknown or revoked |
121
+ | `PermissionDeniedError` | 403: plan or ownership (`model_not_allowed_for_plan`, ...) |
122
+ | `BadRequestError` | 400: e.g. several computers and no `machine=` |
123
+ | `ConflictError` | 409: `machine_busy`, approval no longer pending, ... |
124
+ | `LinkOnlyApprovalError` | the approval must be answered in a browser: `e.approval_url` |
125
+ | `GoneError` | 410: the control session lost its computer; open a new one |
126
+ | `RateLimitError` | 429: a rate limit, or the free plan's daily budget |
127
+ | `ServiceUnavailableError` | 503: the computer did not come online; honor `retry_after` |
128
+
129
+ A task that ran out of credit ends with `result.status == "error"` and `result.error_code` set to
130
+ `insufficient_credits` or `free_computer_time_exhausted`: stop retrying for today.
131
+
132
+ ## Configuration
133
+
134
+ | Argument | Env | Default |
135
+ | ------------- | ------------- | ------------------------ |
136
+ | `api_key` | `SAI_API_KEY` | required |
137
+ | `base_url` | `SAI_API_URL` | `https://api.simular.ai` |
138
+ | `timeout` | | 60 s per request |
139
+
140
+ ## Development
141
+
142
+ ```bash
143
+ cd python
144
+ uv sync
145
+ uv run pytest
146
+ ```
@@ -0,0 +1,31 @@
1
+ [project]
2
+ name = "sai-sdk"
3
+ version = "0.1.0a1"
4
+ description = "Python SDK for the Sai API: delegate desktop work to Sai, or drive a cloud computer directly."
5
+ readme = "README.md"
6
+ requires-python = ">=3.9"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ dependencies = ["httpx>=0.25,<1"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Programming Language :: Python :: 3",
13
+ "Operating System :: OS Independent",
14
+ "Typing :: Typed",
15
+ ]
16
+
17
+ [project.urls]
18
+ Documentation = "https://platform.simular.ai/documentation"
19
+
20
+ [dependency-groups]
21
+ dev = ["pytest>=8"]
22
+
23
+ [build-system]
24
+ requires = ["hatchling>=1.24"]
25
+ build-backend = "hatchling.build"
26
+
27
+ [tool.hatch.build.targets.wheel]
28
+ packages = ["src/sai"]
29
+
30
+ [tool.pytest.ini_options]
31
+ testpaths = ["tests"]
@@ -0,0 +1,69 @@
1
+ """Python SDK for the Sai API.
2
+
3
+ - `sai.sessions`: give Sai a task on a computer and get the answer back.
4
+ - `sai.control_sessions`: drive a cloud computer yourself with Simulang.
5
+ - `sai.machines`, `sai.models`, `sai.files`, `sai.account`.
6
+ """
7
+
8
+ from ._client import Sai
9
+ from ._errors import (
10
+ AuthenticationError,
11
+ BadRequestError,
12
+ BlockError,
13
+ ConflictError,
14
+ ConnectionError,
15
+ GoneError,
16
+ LinkOnlyApprovalError,
17
+ NotFoundError,
18
+ PermissionDeniedError,
19
+ RateLimitError,
20
+ SaiError,
21
+ ServiceUnavailableError,
22
+ )
23
+ from ._version import __version__
24
+ from .control import ControlSession
25
+ from .machines import Machine
26
+ from .sessions import Session, Turn
27
+ from .types import (
28
+ Account,
29
+ Approval,
30
+ Attachment,
31
+ Event,
32
+ ExecError,
33
+ ExecResult,
34
+ LiveView,
35
+ Model,
36
+ TurnResult,
37
+ Usage,
38
+ )
39
+
40
+ __all__ = [
41
+ "Sai",
42
+ "Session",
43
+ "Turn",
44
+ "ControlSession",
45
+ "Machine",
46
+ "Account",
47
+ "Approval",
48
+ "Attachment",
49
+ "Event",
50
+ "ExecError",
51
+ "ExecResult",
52
+ "LiveView",
53
+ "Model",
54
+ "TurnResult",
55
+ "Usage",
56
+ "SaiError",
57
+ "AuthenticationError",
58
+ "BadRequestError",
59
+ "BlockError",
60
+ "ConflictError",
61
+ "ConnectionError",
62
+ "GoneError",
63
+ "LinkOnlyApprovalError",
64
+ "NotFoundError",
65
+ "PermissionDeniedError",
66
+ "RateLimitError",
67
+ "ServiceUnavailableError",
68
+ "__version__",
69
+ ]
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import urllib.parse
5
+ from pathlib import Path
6
+ from typing import Optional, Union
7
+
8
+ import httpx
9
+
10
+ from ._http import DEFAULT_BASE_URL, HTTP
11
+ from .control import ControlSessions
12
+ from .machines import Machines
13
+ from .sessions import Sessions
14
+ from .types import Account, Attachment, Model
15
+
16
+
17
+ class Sai:
18
+ """The Sai API client.
19
+
20
+ ```python
21
+ sai = Sai() # SAI_API_KEY, and SAI_API_URL if set
22
+ session = sai.sessions.create()
23
+ print(session.run("Open Notepad and type hello").text)
24
+ ```
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ api_key: Optional[str] = None,
30
+ *,
31
+ base_url: Optional[str] = None,
32
+ timeout: float = 60.0,
33
+ version_tag: Optional[str] = None,
34
+ transport: Optional[httpx.BaseTransport] = None,
35
+ ) -> None:
36
+ key = api_key or os.environ.get("SAI_API_KEY")
37
+ if not key:
38
+ raise ValueError("No API key: pass api_key= or set SAI_API_KEY. Create one at https://platform.simular.ai.")
39
+ self._http = HTTP(
40
+ key,
41
+ base_url or os.environ.get("SAI_API_URL") or DEFAULT_BASE_URL,
42
+ timeout=timeout,
43
+ version_tag=version_tag,
44
+ transport=transport,
45
+ )
46
+ self.machines = Machines(self)
47
+ self.sessions = Sessions(self)
48
+ self.control_sessions = ControlSessions(self)
49
+ self.models = _Models(self)
50
+ self.files = _Files(self)
51
+ self.account = _Account(self)
52
+
53
+ def close(self) -> None:
54
+ self._http.close()
55
+
56
+ def __enter__(self) -> Sai:
57
+ return self
58
+
59
+ def __exit__(self, *exc: object) -> None:
60
+ self.close()
61
+
62
+
63
+ class _Models:
64
+ def __init__(self, sai: Sai) -> None:
65
+ self._sai = sai
66
+
67
+ def list(self) -> list[Model]:
68
+ """Models a session can run on; `allowed` says whether this plan may use each."""
69
+ d = self._sai._http.json("GET", "/v1/agents/models")
70
+ return [Model.parse(m) for m in d.get("models") or []]
71
+
72
+
73
+ class _Files:
74
+ def __init__(self, sai: Sai) -> None:
75
+ self._sai = sai
76
+
77
+ def upload(self, file: Union[str, Path], *, name: Optional[str] = None) -> Attachment:
78
+ """Upload one file (max 25 MB) to attach to a message."""
79
+ path = Path(file)
80
+ d = self._sai._http.json(
81
+ "POST",
82
+ "/v1/agents/upload",
83
+ content=path.read_bytes(),
84
+ headers={"x-filename": urllib.parse.quote(name or path.name)},
85
+ timeout=120,
86
+ )
87
+ return Attachment.parse(d)
88
+
89
+
90
+ class _Account:
91
+ def __init__(self, sai: Sai) -> None:
92
+ self._sai = sai
93
+
94
+ def get(self) -> Account:
95
+ """Plan and spendable credit. Slow-ish (billing lookup): not for a poll loop."""
96
+ return Account.parse(self._sai._http.json("GET", "/v1/agents/account"))
@@ -0,0 +1,113 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional
4
+
5
+
6
+ class SaiError(Exception):
7
+ """A non-2xx response from the Sai API.
8
+
9
+ `code` is the machine-readable `error` string when the server sent one
10
+ (`machine_busy`, `link_only`, `insufficient_credits`, ...); `message` is the
11
+ human text. `body` is the parsed JSON body, unchanged.
12
+ """
13
+
14
+ def __init__(
15
+ self,
16
+ message: str,
17
+ *,
18
+ status: int = 0,
19
+ code: Optional[str] = None,
20
+ body: Optional[dict[str, Any]] = None,
21
+ retry_after: Optional[float] = None,
22
+ ) -> None:
23
+ super().__init__(message)
24
+ self.message = message
25
+ self.status = status
26
+ self.code = code
27
+ self.body = body or {}
28
+ self.retry_after = retry_after
29
+
30
+ def __repr__(self) -> str:
31
+ return f"{type(self).__name__}(status={self.status}, code={self.code!r}, message={self.message!r})"
32
+
33
+
34
+ class ConnectionError(SaiError):
35
+ """The API could not be reached (DNS, TLS, timeout)."""
36
+
37
+
38
+ class BadRequestError(SaiError):
39
+ """400: the request was invalid, e.g. several computers and no `machine`."""
40
+
41
+
42
+ class AuthenticationError(SaiError):
43
+ """401: no key, or the key is unknown or revoked."""
44
+
45
+
46
+ class PermissionDeniedError(SaiError):
47
+ """403: the key is valid but the account may not do this (plan, ownership)."""
48
+
49
+
50
+ class NotFoundError(SaiError):
51
+ """404."""
52
+
53
+
54
+ class ConflictError(SaiError):
55
+ """409: e.g. `machine_busy`, `link_only`, approval no longer pending."""
56
+
57
+
58
+ class GoneError(SaiError):
59
+ """410: the control session was closed or sat idle. Open a new one."""
60
+
61
+
62
+ class RateLimitError(SaiError):
63
+ """429: a rate limit, or a free-plan budget that resets daily."""
64
+
65
+
66
+ class ServiceUnavailableError(SaiError):
67
+ """503: the computer did not come online, or no capacity. Honor `retry_after`."""
68
+
69
+
70
+ class BlockError(SaiError):
71
+ """A control-session helper's Simulang block failed on the computer. `kind` is
72
+ `exception`, `timeout` or `interrupted`."""
73
+
74
+ def __init__(self, kind: str, message: str) -> None:
75
+ super().__init__(f"{kind}: {message}", code=kind)
76
+ self.kind = kind
77
+
78
+
79
+ class LinkOnlyApprovalError(ConflictError):
80
+ """The approval must be answered by a human in a browser: open `approval_url`."""
81
+
82
+ @property
83
+ def approval_url(self) -> Optional[str]:
84
+ return self.body.get("approvalUrl")
85
+
86
+
87
+ _BY_STATUS = {
88
+ 400: BadRequestError,
89
+ 401: AuthenticationError,
90
+ 403: PermissionDeniedError,
91
+ 404: NotFoundError,
92
+ 409: ConflictError,
93
+ 410: GoneError,
94
+ 429: RateLimitError,
95
+ 503: ServiceUnavailableError,
96
+ }
97
+
98
+
99
+ def error_for(status: int, body: dict[str, Any], retry_after: Optional[float]) -> SaiError:
100
+ err = body.get("error")
101
+ detail = body.get("message")
102
+ # Two body shapes: `{ error: "<human text>" }` (/v1/agents) and
103
+ # `{ error: "<code>", message: "<human text>" }` (/v1/sessions, newer errors).
104
+ code = err if isinstance(err, str) and (detail is not None or _looks_like_code(err)) else None
105
+ message = detail if isinstance(detail, str) else (err if isinstance(err, str) else f"HTTP {status}")
106
+ if status == 401:
107
+ message += " (SAI_API_KEY was rejected: it may be revoked.)"
108
+ cls = LinkOnlyApprovalError if code == "link_only" else _BY_STATUS.get(status, SaiError)
109
+ return cls(message, status=status, code=code, body=body, retry_after=retry_after)
110
+
111
+
112
+ def _looks_like_code(s: str) -> bool:
113
+ return bool(s) and " " not in s and s == s.lower()