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.
- autonai-1.0.0/.gitignore +43 -0
- autonai-1.0.0/PKG-INFO +108 -0
- autonai-1.0.0/README.md +87 -0
- autonai-1.0.0/pyproject.toml +39 -0
- autonai-1.0.0/src/autonai/__init__.py +33 -0
- autonai-1.0.0/src/autonai/_http.py +111 -0
- autonai-1.0.0/src/autonai/client.py +346 -0
- autonai-1.0.0/src/autonai/errors.py +71 -0
- autonai-1.0.0/src/autonai/streaming.py +35 -0
- autonai-1.0.0/tests/test_sdk.py +198 -0
autonai-1.0.0/.gitignore
ADDED
|
@@ -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`.
|
autonai-1.0.0/README.md
ADDED
|
@@ -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"
|