autonai 1.0.0__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,43 @@
1
+ # Python
2
+ __pycache__/
3
+ *.pyc
4
+
5
+ # Build / cargo
6
+ rust/target/
7
+ target/
8
+
9
+ # AUTON local artifacts
10
+ .auton/
11
+ .auton-agents/
12
+ .claude/
13
+ .clawd-todos.json
14
+ .omc/
15
+ .sandbox-home/
16
+ *.local.json
17
+
18
+ # Environment
19
+ .env
20
+ .env.local
21
+ .env.*.local
22
+
23
+ # Node
24
+ node_modules/
25
+ .next/
26
+ out/
27
+ dist/
28
+
29
+ # OS / editor
30
+ .DS_Store
31
+ .idea/
32
+ .vscode/
33
+ *.swp
34
+
35
+ # Internal documentation (kept local, not published)
36
+ docs-Gu-Marca/
37
+ MV-comercial/
38
+ r-docs-extra/
39
+ r-integracaodocs/
40
+ rr-arquitetura-soft-sist/
41
+ rr-newfiles/
42
+ auton-releases-pubkey.asc
43
+ prompt:
autonai-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: autonai
3
+ Version: 1.0.0
4
+ Summary: Official Python SDK for AUTON — the operating system for AI-augmented companies.
5
+ Project-URL: Homepage, https://getauton.ai
6
+ Project-URL: Documentation, https://docs.getauton.ai
7
+ Project-URL: Repository, https://github.com/caio-bessa/auton
8
+ Author-email: AUTON <hello@getauton.ai>
9
+ License: MIT
10
+ Keywords: agents,ai,auton,sdk
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.9
17
+ Requires-Dist: httpx>=0.27
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=8; extra == 'dev'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # autonai
23
+
24
+ Official Python SDK for [AUTON](https://getauton.ai) — the operating system
25
+ for AI-augmented companies. Full docs at
26
+ [docs.getauton.ai](https://docs.getauton.ai).
27
+
28
+ ```bash
29
+ pip install autonai
30
+ ```
31
+
32
+ Requires Python 3.9+.
33
+
34
+ ## Quickstart
35
+
36
+ ```python
37
+ import os
38
+ from autonai import AutonClient
39
+
40
+ client = AutonClient(
41
+ base_url="https://api.getauton.ai",
42
+ api_key=os.environ["AUTON_API_KEY"], # Settings → API keys → auton_pat_…
43
+ )
44
+
45
+ # 1. Spawn a run
46
+ run = client.runs.spawn({
47
+ "agent_id": "…",
48
+ "company_id": "…",
49
+ "area_id": "…",
50
+ "actor_user_id": "…",
51
+ "domain_config": {"template_id": "marketing.performance_marketing_manager"},
52
+ "initial_messages": [
53
+ {"role": "user", "content": "Pull last week's perf data and draft a Slack update"},
54
+ ],
55
+ })
56
+
57
+ # 2. Stream deltas (generator; stops at the terminal delta)
58
+ for delta in client.runs.stream(run["run_id"]):
59
+ print(delta["seq"], delta["kind"])
60
+
61
+ # 3. Triage approvals
62
+ inbox = client.approvals.list(status="pending")
63
+ client.approvals.decide(inbox[0]["id"], verdict="approve", by="…user uuid…", reason="Looks good")
64
+ ```
65
+
66
+ ## More examples
67
+
68
+ ```python
69
+ # Knowledge: upload → search → cite
70
+ client.knowledge.upload(
71
+ company_id=company_id,
72
+ title="Brand book",
73
+ source="upload",
74
+ body="Voice: warm, direct, never jargon-heavy…",
75
+ )
76
+ hits = client.knowledge.search(company_id=company_id, query="brand voice")["hits"]
77
+
78
+ # Billing: this month's spend vs plan cap
79
+ usage = client.billing.usage("this_month")
80
+ print(usage["totals"]["cost_usd_micros"] / 1e6, "USD spent")
81
+
82
+ # Agent manuals: resolve with user preferences inlined
83
+ manual = client.agent_manuals.resolve(
84
+ "marketing.performance_marketing_manager",
85
+ company_id=company_id,
86
+ user_preferences={"manual_tone": "concise", "preferred_locale": "pt-BR"},
87
+ )
88
+
89
+ # Heartbeats: a weekday 9am curator tick
90
+ hb = client.heartbeats.upsert(**heartbeat_fields)
91
+ client.heartbeats.fire_now(hb["id"])
92
+ ```
93
+
94
+ ## Behaviour
95
+
96
+ - **Auth** — `Authorization: Bearer <api_key>` on every call.
97
+ - **Retries** — idempotent GETs retry on 5xx/network (max 3, exponential
98
+ backoff). Non-idempotent calls never retry unless you pass
99
+ `idempotency_key=`.
100
+ - **Rate limits** — `Retry-After` on 429 is honoured automatically; the
101
+ final failure raises `RateLimitError` with `.retry_after`.
102
+ - **Errors** — typed: `ValidationError`, `AuthenticationError`,
103
+ `PermissionError_`, `NotFoundError`, `ConflictError`, `RateLimitError`,
104
+ `ServerError` — all subclassing `AutonError` with `.status` / `.request_id`.
105
+ - **Self-hosting** — pass `orchestrator_url=` / `knowledge_url=` /
106
+ `approvals_url=` when those services are exposed on their own origins.
107
+
108
+ Versioning follows the gateway major: `autonai==1.x` ↔ gateway `1.x`.
@@ -0,0 +1,87 @@
1
+ # autonai
2
+
3
+ Official Python SDK for [AUTON](https://getauton.ai) — the operating system
4
+ for AI-augmented companies. Full docs at
5
+ [docs.getauton.ai](https://docs.getauton.ai).
6
+
7
+ ```bash
8
+ pip install autonai
9
+ ```
10
+
11
+ Requires Python 3.9+.
12
+
13
+ ## Quickstart
14
+
15
+ ```python
16
+ import os
17
+ from autonai import AutonClient
18
+
19
+ client = AutonClient(
20
+ base_url="https://api.getauton.ai",
21
+ api_key=os.environ["AUTON_API_KEY"], # Settings → API keys → auton_pat_…
22
+ )
23
+
24
+ # 1. Spawn a run
25
+ run = client.runs.spawn({
26
+ "agent_id": "…",
27
+ "company_id": "…",
28
+ "area_id": "…",
29
+ "actor_user_id": "…",
30
+ "domain_config": {"template_id": "marketing.performance_marketing_manager"},
31
+ "initial_messages": [
32
+ {"role": "user", "content": "Pull last week's perf data and draft a Slack update"},
33
+ ],
34
+ })
35
+
36
+ # 2. Stream deltas (generator; stops at the terminal delta)
37
+ for delta in client.runs.stream(run["run_id"]):
38
+ print(delta["seq"], delta["kind"])
39
+
40
+ # 3. Triage approvals
41
+ inbox = client.approvals.list(status="pending")
42
+ client.approvals.decide(inbox[0]["id"], verdict="approve", by="…user uuid…", reason="Looks good")
43
+ ```
44
+
45
+ ## More examples
46
+
47
+ ```python
48
+ # Knowledge: upload → search → cite
49
+ client.knowledge.upload(
50
+ company_id=company_id,
51
+ title="Brand book",
52
+ source="upload",
53
+ body="Voice: warm, direct, never jargon-heavy…",
54
+ )
55
+ hits = client.knowledge.search(company_id=company_id, query="brand voice")["hits"]
56
+
57
+ # Billing: this month's spend vs plan cap
58
+ usage = client.billing.usage("this_month")
59
+ print(usage["totals"]["cost_usd_micros"] / 1e6, "USD spent")
60
+
61
+ # Agent manuals: resolve with user preferences inlined
62
+ manual = client.agent_manuals.resolve(
63
+ "marketing.performance_marketing_manager",
64
+ company_id=company_id,
65
+ user_preferences={"manual_tone": "concise", "preferred_locale": "pt-BR"},
66
+ )
67
+
68
+ # Heartbeats: a weekday 9am curator tick
69
+ hb = client.heartbeats.upsert(**heartbeat_fields)
70
+ client.heartbeats.fire_now(hb["id"])
71
+ ```
72
+
73
+ ## Behaviour
74
+
75
+ - **Auth** — `Authorization: Bearer <api_key>` on every call.
76
+ - **Retries** — idempotent GETs retry on 5xx/network (max 3, exponential
77
+ backoff). Non-idempotent calls never retry unless you pass
78
+ `idempotency_key=`.
79
+ - **Rate limits** — `Retry-After` on 429 is honoured automatically; the
80
+ final failure raises `RateLimitError` with `.retry_after`.
81
+ - **Errors** — typed: `ValidationError`, `AuthenticationError`,
82
+ `PermissionError_`, `NotFoundError`, `ConflictError`, `RateLimitError`,
83
+ `ServerError` — all subclassing `AutonError` with `.status` / `.request_id`.
84
+ - **Self-hosting** — pass `orchestrator_url=` / `knowledge_url=` /
85
+ `approvals_url=` when those services are exposed on their own origins.
86
+
87
+ Versioning follows the gateway major: `autonai==1.x` ↔ gateway `1.x`.
@@ -0,0 +1,39 @@
1
+ # autonai — official Python SDK (file 09 PR-278).
2
+ # PyPI org: autonai (created 2026-07-04); publishing uses Trusted Publishers
3
+ # (GitHub OIDC), no long-lived tokens.
4
+
5
+ [build-system]
6
+ requires = ["hatchling"]
7
+ build-backend = "hatchling.build"
8
+
9
+ [project]
10
+ name = "autonai"
11
+ version = "1.0.0"
12
+ description = "Official Python SDK for AUTON — the operating system for AI-augmented companies."
13
+ readme = "README.md"
14
+ license = { text = "MIT" }
15
+ requires-python = ">=3.9"
16
+ authors = [{ name = "AUTON", email = "hello@getauton.ai" }]
17
+ dependencies = ["httpx>=0.27"]
18
+ keywords = ["auton", "agents", "ai", "sdk"]
19
+ classifiers = [
20
+ "Development Status :: 4 - Beta",
21
+ "Intended Audience :: Developers",
22
+ "License :: OSI Approved :: MIT License",
23
+ "Programming Language :: Python :: 3",
24
+ "Typing :: Typed",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://getauton.ai"
29
+ Documentation = "https://docs.getauton.ai"
30
+ Repository = "https://github.com/caio-bessa/auton"
31
+
32
+ [project.optional-dependencies]
33
+ dev = ["pytest>=8"]
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/autonai"]
37
+
38
+ [tool.pytest.ini_options]
39
+ testpaths = ["tests"]
@@ -0,0 +1,33 @@
1
+ """autonai — official Python SDK for AUTON (file 09 PR-278)."""
2
+
3
+ from ._http import SDK_VERSION, USER_AGENT
4
+ from .client import AutonClient
5
+ from .errors import (
6
+ AutonError,
7
+ AuthenticationError,
8
+ ConflictError,
9
+ NotFoundError,
10
+ PermissionError_,
11
+ RateLimitError,
12
+ ServerError,
13
+ ValidationError,
14
+ )
15
+ from .streaming import stream_deltas
16
+
17
+ __version__ = SDK_VERSION
18
+
19
+ __all__ = [
20
+ "AutonClient",
21
+ "AutonError",
22
+ "AuthenticationError",
23
+ "ConflictError",
24
+ "NotFoundError",
25
+ "PermissionError_",
26
+ "RateLimitError",
27
+ "ServerError",
28
+ "ValidationError",
29
+ "SDK_VERSION",
30
+ "USER_AGENT",
31
+ "stream_deltas",
32
+ "__version__",
33
+ ]
@@ -0,0 +1,111 @@
1
+ """Transport core (file 09 PR-278): auth, retry/backoff, Retry-After.
2
+
3
+ Same policy as the TypeScript SDK: idempotent GETs retry on 5xx/network up
4
+ to ``max_retries`` with exponential backoff; non-idempotent methods never
5
+ retry unless an ``idempotency_key`` is supplied; 429 honours ``Retry-After``
6
+ on every method.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ from typing import Any, Callable, Dict, Optional
13
+
14
+ import httpx
15
+
16
+ from .errors import RateLimitError, error_for_status
17
+
18
+ SDK_VERSION = "1.0.0"
19
+ USER_AGENT = f"autonai-sdk-py/{SDK_VERSION}"
20
+
21
+
22
+ class Transport:
23
+ def __init__(
24
+ self,
25
+ base_url: str,
26
+ api_key: str,
27
+ *,
28
+ max_retries: int = 3,
29
+ timeout: float = 30.0,
30
+ http_client: Optional[httpx.Client] = None,
31
+ sleep: Callable[[float], None] = time.sleep,
32
+ ) -> None:
33
+ if not base_url:
34
+ raise ValueError("base_url is required")
35
+ if not api_key:
36
+ raise ValueError("api_key is required")
37
+ self._base_url = base_url.rstrip("/")
38
+ self._api_key = api_key
39
+ self._max_retries = max_retries
40
+ self._sleep = sleep
41
+ self._client = http_client or httpx.Client(timeout=timeout)
42
+
43
+ def close(self) -> None:
44
+ self._client.close()
45
+
46
+ def request(
47
+ self,
48
+ path: str,
49
+ *,
50
+ method: str = "GET",
51
+ body: Any = None,
52
+ query: Optional[Dict[str, Any]] = None,
53
+ idempotency_key: Optional[str] = None,
54
+ base_url: Optional[str] = None,
55
+ ) -> Any:
56
+ retryable = method == "GET" or idempotency_key is not None
57
+ url = (base_url.rstrip("/") if base_url else self._base_url) + path
58
+ headers = {
59
+ "authorization": f"Bearer {self._api_key}",
60
+ "user-agent": USER_AGENT,
61
+ }
62
+ if idempotency_key:
63
+ headers["idempotency-key"] = idempotency_key
64
+ params = {k: v for k, v in (query or {}).items() if v is not None}
65
+
66
+ attempt = 0
67
+ while True:
68
+ attempt += 1
69
+ try:
70
+ response = self._client.request(
71
+ method, url, json=body, params=params, headers=headers
72
+ )
73
+ except httpx.TransportError:
74
+ if retryable and attempt <= self._max_retries:
75
+ self._sleep(_backoff_secs(attempt))
76
+ continue
77
+ raise
78
+
79
+ if response.is_success:
80
+ if response.status_code == 204 or not response.content:
81
+ return None
82
+ return response.json()
83
+
84
+ request_id = response.headers.get("x-request-id")
85
+ retry_after_header = response.headers.get("retry-after")
86
+ retry_after = float(retry_after_header) if retry_after_header else None
87
+ message = _error_message(response)
88
+
89
+ if response.status_code == 429 and attempt <= self._max_retries:
90
+ self._sleep(retry_after if retry_after is not None else 1.0)
91
+ continue
92
+ if response.status_code >= 500 and retryable and attempt <= self._max_retries:
93
+ self._sleep(_backoff_secs(attempt))
94
+ continue
95
+ if response.status_code == 429:
96
+ raise RateLimitError(message, 429, retry_after, request_id)
97
+ raise error_for_status(response.status_code, message, retry_after, request_id)
98
+
99
+
100
+ def _backoff_secs(attempt: int) -> float:
101
+ return min(0.25 * (2 ** (attempt - 1)), 4.0)
102
+
103
+
104
+ def _error_message(response: httpx.Response) -> str:
105
+ try:
106
+ payload = response.json()
107
+ if isinstance(payload, dict) and isinstance(payload.get("error"), str):
108
+ return payload["error"]
109
+ except ValueError:
110
+ pass
111
+ return f"HTTP {response.status_code}"
@@ -0,0 +1,346 @@
1
+ """AutonClient (file 09 PR-278) — typed namespaces over the public surface.
2
+
3
+ >>> from autonai import AutonClient
4
+ >>> client = AutonClient(base_url="https://api.getauton.ai", api_key=key)
5
+ >>> run = client.runs.spawn({"agent_id": ..., "mission": ...})
6
+ >>> for delta in client.runs.stream(run["run_id"]):
7
+ ... ...
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Dict, Iterator, List, Optional
13
+
14
+ from ._http import Transport
15
+ from .streaming import stream_deltas
16
+
17
+ Json = Dict[str, Any]
18
+
19
+
20
+ class AutonClient:
21
+ """Synchronous client. The gateway is the public door; self-hosters can
22
+ point ``orchestrator_url`` / ``knowledge_url`` / ``approvals_url`` at
23
+ directly-exposed services."""
24
+
25
+ def __init__(
26
+ self,
27
+ *,
28
+ base_url: str,
29
+ api_key: str,
30
+ orchestrator_url: Optional[str] = None,
31
+ knowledge_url: Optional[str] = None,
32
+ approvals_url: Optional[str] = None,
33
+ max_retries: int = 3,
34
+ timeout: float = 30.0,
35
+ http_client: Any = None,
36
+ sleep: Any = None,
37
+ ) -> None:
38
+ kwargs: Dict[str, Any] = {"max_retries": max_retries, "timeout": timeout}
39
+ if http_client is not None:
40
+ kwargs["http_client"] = http_client
41
+ if sleep is not None:
42
+ kwargs["sleep"] = sleep
43
+ self._tx = Transport(base_url, api_key, **kwargs)
44
+ self._orchestrator_url = orchestrator_url
45
+ self._knowledge_url = knowledge_url
46
+ self._approvals_url = approvals_url
47
+
48
+ self.runs = _Runs(self)
49
+ self.approvals = _Approvals(self)
50
+ self.knowledge = _Knowledge(self)
51
+ self.billing = _Billing(self)
52
+ self.api_keys = _ApiKeys(self)
53
+ self.process_modules = _ProcessModules(self)
54
+ self.agent_manuals = _AgentManuals(self)
55
+ self.heartbeats = _Heartbeats(self)
56
+ self.proposals = _Proposals(self)
57
+ self.preferences = _Preferences(self)
58
+ self.tenant = _Tenant(self)
59
+
60
+ def health(self) -> Any:
61
+ return self._tx.request("/readyz")
62
+
63
+ def close(self) -> None:
64
+ self._tx.close()
65
+
66
+ def __enter__(self) -> "AutonClient":
67
+ return self
68
+
69
+ def __exit__(self, *exc: Any) -> None:
70
+ self.close()
71
+
72
+
73
+ class _Runs:
74
+ def __init__(self, client: AutonClient) -> None:
75
+ self._c = client
76
+
77
+ def spawn(self, body: Json, *, idempotency_key: Optional[str] = None) -> Json:
78
+ return self._c._tx.request(
79
+ "/v1/runs", method="POST", body=body, idempotency_key=idempotency_key
80
+ )
81
+
82
+ def deltas(self, run_id: str, *, from_seq: int = 0, limit: Optional[int] = None) -> Json:
83
+ return self._c._tx.request(
84
+ f"/v1/runs/{run_id}/deltas",
85
+ query={"from_seq": from_seq, "limit": limit},
86
+ base_url=self._c._orchestrator_url,
87
+ )
88
+
89
+ def stream(self, run_id: str, **kwargs: Any) -> Iterator[Json]:
90
+ return stream_deltas(lambda seq: self.deltas(run_id, from_seq=seq), **kwargs)
91
+
92
+ def intervene(self, run_id: str, action: Json) -> Json:
93
+ return self._c._tx.request(
94
+ f"/v1/runs/{run_id}/intervene",
95
+ method="POST",
96
+ body=action,
97
+ base_url=self._c._orchestrator_url,
98
+ )
99
+
100
+
101
+ class _Approvals:
102
+ def __init__(self, client: AutonClient) -> None:
103
+ self._c = client
104
+
105
+ def list(self, *, status: Optional[str] = None, company_id: Optional[str] = None) -> List[Json]:
106
+ out = self._c._tx.request(
107
+ "/v1/approvals",
108
+ query={"status": status, "company_id": company_id},
109
+ base_url=self._c._approvals_url,
110
+ )
111
+ return out["items"]
112
+
113
+ def decide(
114
+ self,
115
+ approval_id: str,
116
+ *,
117
+ verdict: str,
118
+ by: str,
119
+ reason: Optional[str] = None,
120
+ edited_payload: Any = None,
121
+ ) -> Json:
122
+ return self._c._tx.request(
123
+ f"/v1/approvals/{approval_id}",
124
+ method="PATCH",
125
+ body={
126
+ "verdict": verdict,
127
+ "by": by,
128
+ "reason": reason,
129
+ "edited_payload": edited_payload,
130
+ },
131
+ base_url=self._c._approvals_url,
132
+ )
133
+
134
+ def decide_bulk(
135
+ self, *, ids: List[str], verdict: str, by: str, reason: Optional[str] = None
136
+ ) -> Json:
137
+ return self._c._tx.request(
138
+ "/v1/approvals/bulk",
139
+ method="PATCH",
140
+ body={"ids": ids, "verdict": verdict, "by": by, "reason": reason},
141
+ base_url=self._c._approvals_url,
142
+ )
143
+
144
+
145
+ class _Knowledge:
146
+ def __init__(self, client: AutonClient) -> None:
147
+ self._c = client
148
+
149
+ def upload(self, **body: Any) -> Json:
150
+ return self._c._tx.request(
151
+ "/v1/knowledge/upload", method="POST", body=body, base_url=self._c._knowledge_url
152
+ )
153
+
154
+ def search(self, **body: Any) -> Json:
155
+ return self._c._tx.request(
156
+ "/v1/knowledge/search", method="POST", body=body, base_url=self._c._knowledge_url
157
+ )
158
+
159
+ def list(self, company_id: str) -> Json:
160
+ return self._c._tx.request(
161
+ "/v1/knowledge/documents",
162
+ query={"company_id": company_id},
163
+ base_url=self._c._knowledge_url,
164
+ )
165
+
166
+ def remove(self, document_id: str) -> None:
167
+ self._c._tx.request(
168
+ f"/v1/knowledge/documents/{document_id}",
169
+ method="DELETE",
170
+ base_url=self._c._knowledge_url,
171
+ )
172
+
173
+ def reindex(self, document_id: str) -> Json:
174
+ return self._c._tx.request(
175
+ f"/v1/knowledge/documents/{document_id}/reindex",
176
+ method="POST",
177
+ base_url=self._c._knowledge_url,
178
+ )
179
+
180
+
181
+ class _Billing:
182
+ def __init__(self, client: AutonClient) -> None:
183
+ self._c = client
184
+
185
+ def plans(self) -> Json:
186
+ return self._c._tx.request("/v1/billing/plans")
187
+
188
+ def subscription(self) -> Json:
189
+ return self._c._tx.request("/v1/billing/subscription")
190
+
191
+ def usage(self, period: str = "this_month") -> Json:
192
+ return self._c._tx.request("/v1/billing/usage", query={"period": period})
193
+
194
+ def checkout(self, *, plan: str, period: str) -> Json:
195
+ return self._c._tx.request(
196
+ "/v1/billing/checkout", method="POST", body={"plan": plan, "period": period}
197
+ )
198
+
199
+ def portal(self) -> Json:
200
+ return self._c._tx.request("/v1/billing/portal", method="POST")
201
+
202
+
203
+ class _ApiKeys:
204
+ def __init__(self, client: AutonClient) -> None:
205
+ self._c = client
206
+
207
+ def create(self, *, name: str, scopes: Optional[List[str]] = None) -> Json:
208
+ return self._c._tx.request(
209
+ "/v1/api-keys", method="POST", body={"name": name, "scopes": scopes or []}
210
+ )
211
+
212
+ def list(self) -> Json:
213
+ return self._c._tx.request("/v1/api-keys")
214
+
215
+ def revoke(self, key_id: str) -> Json:
216
+ return self._c._tx.request(f"/v1/api-keys/{key_id}/revoke", method="POST")
217
+
218
+
219
+ class _ProcessModules:
220
+ def __init__(self, client: AutonClient) -> None:
221
+ self._c = client
222
+
223
+ def list(self) -> Json:
224
+ return self._c._tx.request("/v1/process-modules")
225
+
226
+ def detail(self, module_id: str) -> Json:
227
+ return self._c._tx.request(f"/v1/process-modules/{module_id}")
228
+
229
+ def manual(self, module_id: str, *, company_id: Optional[str] = None) -> Json:
230
+ return self._c._tx.request(
231
+ f"/v1/process-modules/{module_id}/manual", query={"company_id": company_id}
232
+ )
233
+
234
+ def agents(self, module_id: str) -> Json:
235
+ return self._c._tx.request(f"/v1/process-modules/{module_id}/agents")
236
+
237
+
238
+ class _AgentManuals:
239
+ def __init__(self, client: AutonClient) -> None:
240
+ self._c = client
241
+
242
+ def list(self) -> Json:
243
+ return self._c._tx.request("/v1/agent-manuals")
244
+
245
+ def detail(self, template_id: str, *, company_id: Optional[str] = None) -> Json:
246
+ return self._c._tx.request(
247
+ f"/v1/agent-manuals/{template_id}", query={"company_id": company_id}
248
+ )
249
+
250
+ def resolve(self, template_id: str, **body: Any) -> Json:
251
+ return self._c._tx.request(
252
+ f"/v1/agent-manuals/{template_id}/resolved", method="POST", body=body
253
+ )
254
+
255
+ def save_section(self, template_id: str, section: str, **body: Any) -> Json:
256
+ return self._c._tx.request(
257
+ f"/v1/agent-manuals/{template_id}/sections/{section}", method="PUT", body=body
258
+ )
259
+
260
+
261
+ class _Heartbeats:
262
+ def __init__(self, client: AutonClient) -> None:
263
+ self._c = client
264
+
265
+ def list(self) -> Json:
266
+ return self._c._tx.request("/v1/heartbeats", base_url=self._c._orchestrator_url)
267
+
268
+ def upsert(self, **heartbeat: Any) -> Json:
269
+ return self._c._tx.request(
270
+ "/v1/heartbeats", method="POST", body=heartbeat, base_url=self._c._orchestrator_url
271
+ )
272
+
273
+ def fire_now(self, heartbeat_id: str) -> Json:
274
+ return self._c._tx.request(
275
+ f"/v1/heartbeats/{heartbeat_id}/fire",
276
+ method="POST",
277
+ base_url=self._c._orchestrator_url,
278
+ )
279
+
280
+ def remove(self, heartbeat_id: str) -> None:
281
+ self._c._tx.request(
282
+ f"/v1/heartbeats/{heartbeat_id}",
283
+ method="DELETE",
284
+ base_url=self._c._orchestrator_url,
285
+ )
286
+
287
+
288
+ class _Proposals:
289
+ def __init__(self, client: AutonClient) -> None:
290
+ self._c = client
291
+
292
+ def list(self, *, company_id: str, status: str = "pending") -> Json:
293
+ return self._c._tx.request(
294
+ "/v1/proposals",
295
+ query={"company_id": company_id, "status": status},
296
+ base_url=self._c._orchestrator_url,
297
+ )
298
+
299
+ def resolve(self, proposal_id: str, *, status: str) -> Json:
300
+ return self._c._tx.request(
301
+ f"/v1/proposals/{proposal_id}",
302
+ method="PATCH",
303
+ body={"status": status},
304
+ base_url=self._c._orchestrator_url,
305
+ )
306
+
307
+
308
+ class _Preferences:
309
+ def __init__(self, client: AutonClient) -> None:
310
+ self._c = client
311
+
312
+ def get(self) -> Json:
313
+ return self._c._tx.request("/v1/user-preferences")
314
+
315
+ def set(self, prefs: Json) -> Json:
316
+ return self._c._tx.request("/v1/user-preferences", method="PUT", body={"prefs": prefs})
317
+
318
+
319
+ class _Tenant:
320
+ def __init__(self, client: AutonClient) -> None:
321
+ self._c = client
322
+
323
+ def charter(self, company_id: str) -> Json:
324
+ return self._c._tx.request(f"/v1/tenants/{company_id}/charter")
325
+
326
+ def install_charter(self, company_id: str, charter: Json) -> Json:
327
+ return self._c._tx.request(
328
+ f"/v1/tenants/{company_id}/charter", method="PUT", body=charter
329
+ )
330
+
331
+ def patch_charter_section(self, company_id: str, section: str, body: Json) -> Json:
332
+ return self._c._tx.request(
333
+ f"/v1/tenants/{company_id}/charter/sections/{section}", method="PATCH", body=body
334
+ )
335
+
336
+ def events(self, company_id: str, **query: Any) -> Json:
337
+ return self._c._tx.request(f"/v1/tenants/{company_id}/events", query=query)
338
+
339
+ def locks(self, company_id: str) -> Json:
340
+ return self._c._tx.request(f"/v1/tenants/{company_id}/locks")
341
+
342
+ def company_progress(self, company_id: str, **query: Any) -> Json:
343
+ return self._c._tx.request(f"/v1/tenants/{company_id}/company-progress", query=query)
344
+
345
+ def swimlanes(self, company_id: str) -> Json:
346
+ return self._c._tx.request(f"/v1/tenants/{company_id}/swimlanes")
@@ -0,0 +1,71 @@
1
+ """Typed error hierarchy (file 09 PR-278)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+
8
+ class AutonError(Exception):
9
+ """Base for every non-2xx response."""
10
+
11
+ def __init__(self, message: str, status: int, request_id: Optional[str] = None) -> None:
12
+ super().__init__(message)
13
+ self.status = status
14
+ self.request_id = request_id
15
+
16
+
17
+ class ValidationError(AutonError):
18
+ pass
19
+
20
+
21
+ class AuthenticationError(AutonError):
22
+ pass
23
+
24
+
25
+ class PermissionError_(AutonError): # noqa: N801 — avoid shadowing builtins.PermissionError
26
+ pass
27
+
28
+
29
+ class NotFoundError(AutonError):
30
+ pass
31
+
32
+
33
+ class ConflictError(AutonError):
34
+ pass
35
+
36
+
37
+ class ServerError(AutonError):
38
+ pass
39
+
40
+
41
+ class RateLimitError(AutonError):
42
+ def __init__(
43
+ self,
44
+ message: str,
45
+ status: int,
46
+ retry_after: Optional[float],
47
+ request_id: Optional[str] = None,
48
+ ) -> None:
49
+ super().__init__(message, status, request_id)
50
+ self.retry_after = retry_after
51
+
52
+
53
+ def error_for_status(
54
+ status: int,
55
+ message: str,
56
+ retry_after: Optional[float],
57
+ request_id: Optional[str] = None,
58
+ ) -> AutonError:
59
+ if status in (400, 422):
60
+ return ValidationError(message, status, request_id)
61
+ if status == 401:
62
+ return AuthenticationError(message, status, request_id)
63
+ if status == 403:
64
+ return PermissionError_(message, status, request_id)
65
+ if status == 404:
66
+ return NotFoundError(message, status, request_id)
67
+ if status == 409:
68
+ return ConflictError(message, status, request_id)
69
+ if status == 429:
70
+ return RateLimitError(message, status, retry_after, request_id)
71
+ return ServerError(message, status, request_id)
@@ -0,0 +1,35 @@
1
+ """Delta streaming (file 09 PR-278): consumer-paced generator over the
2
+ replayable ``/deltas?from_seq=`` endpoint."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import time
7
+ from typing import Any, Callable, Dict, Iterator, List, Optional, Sequence
8
+
9
+ TERMINAL_KINDS = ("run_completed", "run_failed", "run_cancelled")
10
+
11
+
12
+ def stream_deltas(
13
+ fetch_page: Callable[[int], Dict[str, Any]],
14
+ *,
15
+ interval_secs: float = 1.0,
16
+ terminal_kinds: Sequence[str] = TERMINAL_KINDS,
17
+ max_polls: Optional[int] = None,
18
+ sleep: Callable[[float], None] = time.sleep,
19
+ ) -> Iterator[Dict[str, Any]]:
20
+ """Yield deltas in order; stop after a terminal kind. Back-pressure is
21
+ inherent — the next poll fires only when the consumer resumes."""
22
+ terminal = set(terminal_kinds)
23
+ from_seq = 0
24
+ polls = 0
25
+ while max_polls is None or polls < max_polls:
26
+ polls += 1
27
+ page = fetch_page(from_seq)
28
+ deltas: List[Dict[str, Any]] = page.get("deltas", [])
29
+ for delta in deltas:
30
+ from_seq = max(from_seq, int(delta.get("seq", from_seq)) + 1)
31
+ yield delta
32
+ if delta.get("kind") in terminal:
33
+ return
34
+ if not deltas:
35
+ sleep(interval_secs)
@@ -0,0 +1,198 @@
1
+ """SDK contract tests (file 09 PR-278) against an in-process http.server —
2
+ auth header, retries, Retry-After, typed errors, streaming."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import threading
8
+ from http.server import BaseHTTPRequestHandler, HTTPServer
9
+ from typing import Any, Callable, Dict, List, Optional
10
+
11
+ import pytest
12
+
13
+ from autonai import (
14
+ AuthenticationError,
15
+ AutonClient,
16
+ RateLimitError,
17
+ ValidationError,
18
+ )
19
+
20
+ Handler = Callable[[str, str, Dict[str, str], bytes], "Reply"]
21
+
22
+
23
+ class Reply:
24
+ def __init__(self, status: int, body: Any = None, headers: Optional[Dict[str, str]] = None):
25
+ self.status = status
26
+ self.body = body
27
+ self.headers = headers or {}
28
+
29
+
30
+ class _Server:
31
+ def __init__(self) -> None:
32
+ self.seen: List[Dict[str, Any]] = []
33
+ self.handler: Handler = lambda m, p, h, b: Reply(200, {})
34
+ outer = self
35
+
36
+ class RequestHandler(BaseHTTPRequestHandler):
37
+ def _serve(self) -> None:
38
+ length = int(self.headers.get("content-length") or 0)
39
+ body = self.rfile.read(length) if length else b""
40
+ outer.seen.append(
41
+ {
42
+ "method": self.command,
43
+ "path": self.path,
44
+ "headers": {k.lower(): v for k, v in self.headers.items()},
45
+ "body": body.decode() if body else "",
46
+ }
47
+ )
48
+ reply = outer.handler(self.command, self.path, dict(self.headers), body)
49
+ payload = b"" if reply.body is None else json.dumps(reply.body).encode()
50
+ self.send_response(reply.status)
51
+ self.send_header("content-type", "application/json")
52
+ for k, v in reply.headers.items():
53
+ self.send_header(k, v)
54
+ self.send_header("content-length", str(len(payload)))
55
+ self.end_headers()
56
+ if payload:
57
+ self.wfile.write(payload)
58
+
59
+ do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = _serve
60
+
61
+ def log_message(self, *args: Any) -> None: # silence
62
+ pass
63
+
64
+ self.httpd = HTTPServer(("127.0.0.1", 0), RequestHandler)
65
+ self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
66
+ self.thread.start()
67
+ self.url = f"http://127.0.0.1:{self.httpd.server_port}"
68
+
69
+ def stop(self) -> None:
70
+ self.httpd.shutdown()
71
+ self.httpd.server_close()
72
+
73
+
74
+ @pytest.fixture()
75
+ def server():
76
+ s = _Server()
77
+ yield s
78
+ s.stop()
79
+
80
+
81
+ def make_client(server: _Server, **kwargs: Any) -> AutonClient:
82
+ kwargs.setdefault("sleep", lambda _s: None)
83
+ return AutonClient(base_url=server.url, api_key="auton_pat_test", **kwargs)
84
+
85
+
86
+ def test_auth_header_and_user_agent(server: _Server) -> None:
87
+ server.handler = lambda m, p, h, b: Reply(200, {"items": [], "count": 0})
88
+ with make_client(server) as client:
89
+ client.api_keys.list()
90
+ assert server.seen[0]["headers"]["authorization"] == "Bearer auton_pat_test"
91
+ assert server.seen[0]["headers"]["user-agent"].startswith("autonai-sdk-py/1.")
92
+
93
+
94
+ def test_runs_spawn_and_idempotent_retry(server: _Server) -> None:
95
+ calls = {"n": 0}
96
+
97
+ def handler(method: str, path: str, headers: Dict[str, str], body: bytes) -> Reply:
98
+ calls["n"] += 1
99
+ if calls["n"] == 1:
100
+ return Reply(503, {"error": "warming up"})
101
+ return Reply(202, {"run_id": "r-9"})
102
+
103
+ server.handler = handler
104
+ with make_client(server) as client:
105
+ run = client.runs.spawn({"mission": "draft"}, idempotency_key="idem-1")
106
+ assert run["run_id"] == "r-9"
107
+ assert calls["n"] == 2
108
+ assert server.seen[0]["headers"]["idempotency-key"] == "idem-1"
109
+
110
+
111
+ def test_non_idempotent_post_never_retries(server: _Server) -> None:
112
+ calls = {"n": 0}
113
+
114
+ def handler(method: str, path: str, headers: Dict[str, str], body: bytes) -> Reply:
115
+ calls["n"] += 1
116
+ return Reply(500, {"error": "boom"})
117
+
118
+ server.handler = handler
119
+ with make_client(server) as client:
120
+ with pytest.raises(Exception, match="boom"):
121
+ client.runs.spawn({"mission": "x"})
122
+ assert calls["n"] == 1
123
+
124
+
125
+ def test_retry_after_honoured_on_429(server: _Server) -> None:
126
+ waits: List[float] = []
127
+ calls = {"n": 0}
128
+
129
+ def handler(method: str, path: str, headers: Dict[str, str], body: bytes) -> Reply:
130
+ calls["n"] += 1
131
+ if calls["n"] == 1:
132
+ return Reply(429, {"error": "quota"}, {"retry-after": "3"})
133
+ return Reply(200, {"items": [], "count": 0})
134
+
135
+ server.handler = handler
136
+ with make_client(server, sleep=waits.append) as client:
137
+ client.api_keys.list()
138
+ assert 3.0 in waits
139
+
140
+
141
+ def test_typed_errors(server: _Server) -> None:
142
+ server.handler = lambda m, p, h, b: Reply(401, {"error": "bad token"})
143
+ with make_client(server, max_retries=0) as client:
144
+ with pytest.raises(AuthenticationError):
145
+ client.api_keys.list()
146
+
147
+ server.handler = lambda m, p, h, b: Reply(400, {"error": "name required"})
148
+ with make_client(server) as client:
149
+ with pytest.raises(ValidationError):
150
+ client.api_keys.create(name="")
151
+
152
+ server.handler = lambda m, p, h, b: Reply(429, {"error": "limit"}, {"retry-after": "5"})
153
+ with make_client(server, max_retries=0) as client:
154
+ with pytest.raises(RateLimitError) as excinfo:
155
+ client.api_keys.list()
156
+ assert excinfo.value.retry_after == 5.0
157
+
158
+
159
+ def test_stream_yields_until_terminal(server: _Server) -> None:
160
+ pages = [
161
+ {"deltas": [{"run_id": "r", "seq": 0, "kind": "thinking", "payload": {}}], "high_water": 1},
162
+ {"deltas": [], "high_water": 1},
163
+ {
164
+ "deltas": [
165
+ {"run_id": "r", "seq": 1, "kind": "text", "payload": {"assistant": "hi"}},
166
+ {"run_id": "r", "seq": 2, "kind": "run_completed", "payload": {}},
167
+ {"run_id": "r", "seq": 3, "kind": "never_seen", "payload": {}},
168
+ ],
169
+ "high_water": 4,
170
+ },
171
+ ]
172
+ calls = {"n": 0}
173
+
174
+ def handler(method: str, path: str, headers: Dict[str, str], body: bytes) -> Reply:
175
+ page = pages[min(calls["n"], len(pages) - 1)]
176
+ calls["n"] += 1
177
+ return Reply(200, page)
178
+
179
+ server.handler = handler
180
+ with make_client(server) as client:
181
+ kinds = [d["kind"] for d in client.runs.stream("r", sleep=lambda _s: None)]
182
+ assert kinds == ["thinking", "text", "run_completed"]
183
+ assert "from_seq=1" in server.seen[-1]["path"]
184
+
185
+
186
+ def test_orchestrator_url_override(server: _Server) -> None:
187
+ server.handler = lambda m, p, h, b: Reply(200, {"items": [], "count": 0})
188
+ client = AutonClient(
189
+ base_url="http://never-called.invalid",
190
+ api_key="k",
191
+ orchestrator_url=server.url,
192
+ sleep=lambda _s: None,
193
+ )
194
+ try:
195
+ client.heartbeats.list()
196
+ finally:
197
+ client.close()
198
+ assert server.seen[0]["path"] == "/v1/heartbeats"