eolaswork 0.1.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.
- eolaswork-0.1.0/.gitignore +11 -0
- eolaswork-0.1.0/PKG-INFO +140 -0
- eolaswork-0.1.0/README.md +114 -0
- eolaswork-0.1.0/pyproject.toml +56 -0
- eolaswork-0.1.0/release.sh +33 -0
- eolaswork-0.1.0/src/eolaswork/__init__.py +77 -0
- eolaswork-0.1.0/src/eolaswork/_atransport.py +123 -0
- eolaswork-0.1.0/src/eolaswork/_config.py +74 -0
- eolaswork-0.1.0/src/eolaswork/_streaming.py +65 -0
- eolaswork-0.1.0/src/eolaswork/_transport.py +175 -0
- eolaswork-0.1.0/src/eolaswork/async_client.py +75 -0
- eolaswork-0.1.0/src/eolaswork/client.py +77 -0
- eolaswork-0.1.0/src/eolaswork/errors.py +126 -0
- eolaswork-0.1.0/src/eolaswork/resources/__init__.py +1 -0
- eolaswork-0.1.0/src/eolaswork/resources/_base.py +28 -0
- eolaswork-0.1.0/src/eolaswork/resources/account.py +63 -0
- eolaswork-0.1.0/src/eolaswork/resources/api_keys.py +45 -0
- eolaswork-0.1.0/src/eolaswork/resources/files.py +180 -0
- eolaswork-0.1.0/src/eolaswork/resources/followups.py +73 -0
- eolaswork-0.1.0/src/eolaswork/resources/memory.py +74 -0
- eolaswork-0.1.0/src/eolaswork/resources/models.py +35 -0
- eolaswork-0.1.0/src/eolaswork/resources/roles.py +75 -0
- eolaswork-0.1.0/src/eolaswork/resources/runs.py +217 -0
- eolaswork-0.1.0/src/eolaswork/resources/skills.py +60 -0
- eolaswork-0.1.0/src/eolaswork/resources/tasks.py +156 -0
- eolaswork-0.1.0/src/eolaswork/resources/teams.py +67 -0
- eolaswork-0.1.0/src/eolaswork/types.py +289 -0
- eolaswork-0.1.0/src/eolaswork/webhooks.py +121 -0
- eolaswork-0.1.0/tests/conftest.py +14 -0
- eolaswork-0.1.0/tests/integration/conftest.py +13 -0
- eolaswork-0.1.0/tests/integration/test_e2e.py +58 -0
- eolaswork-0.1.0/tests/test_smoke.py +4 -0
eolaswork-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: eolaswork
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the EolasWork agentic platform
|
|
5
|
+
Project-URL: Homepage, https://eolaswork.com
|
|
6
|
+
Project-URL: Documentation, https://docs.eolaswork.com/sdk/python
|
|
7
|
+
Project-URL: Repository, https://github.com/eolasflow/eolaswork-python
|
|
8
|
+
Author: EolasWork
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: agents,ai,automation,eolaswork,llm
|
|
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: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Requires-Dist: httpx-sse<1.0,>=0.4
|
|
20
|
+
Requires-Dist: httpx<1.0,>=0.27
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
24
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# eolaswork
|
|
28
|
+
|
|
29
|
+
Official Python SDK for the [EolasWork](https://eolaswork.com) agentic platform.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
pip install eolaswork
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Python 3.11+. Sync + async clients ship together.
|
|
38
|
+
|
|
39
|
+
## Quickstart
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from eolaswork import Client
|
|
43
|
+
|
|
44
|
+
client = Client(api_key="nxa_...") # or set EOLASWORK_API_KEY
|
|
45
|
+
|
|
46
|
+
# Discover
|
|
47
|
+
me = client.account.whoami()
|
|
48
|
+
roles = client.roles.list()
|
|
49
|
+
models = client.models.list()
|
|
50
|
+
|
|
51
|
+
# Create a task + run an agent
|
|
52
|
+
task = client.tasks.create(title="Q2 board prep")
|
|
53
|
+
client.files.upload(task.id, "./Q2_sales.xlsx")
|
|
54
|
+
|
|
55
|
+
run = client.runs.create(
|
|
56
|
+
task_id=task.id,
|
|
57
|
+
prompt="Build a board-ready summary from the Excel I attached.",
|
|
58
|
+
role="research-analyst",
|
|
59
|
+
model="claude-opus-4-7",
|
|
60
|
+
skills=["xlsx-reader"],
|
|
61
|
+
webhook_url="https://my-app.example.com/eolaswork/hook", # optional
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Three ways to wait
|
|
65
|
+
final = client.runs.wait(run.id, timeout=300) # blocks
|
|
66
|
+
# or:
|
|
67
|
+
for ev in client.runs.stream(run.id): # live SSE
|
|
68
|
+
print(ev.kind, ev.payload)
|
|
69
|
+
# or: don't wait; let the webhook arrive at your endpoint.
|
|
70
|
+
|
|
71
|
+
print(final.status, final.output)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Async
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
import asyncio
|
|
78
|
+
from eolaswork import AsyncClient
|
|
79
|
+
|
|
80
|
+
async def main():
|
|
81
|
+
async with AsyncClient(api_key="nxa_...") as client:
|
|
82
|
+
task = await client.tasks.create(title="async run")
|
|
83
|
+
run = await client.runs.create(task_id=task.id, prompt="...")
|
|
84
|
+
async for ev in client.runs.astream(run.id):
|
|
85
|
+
print(ev)
|
|
86
|
+
|
|
87
|
+
asyncio.run(main())
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Webhook receiver
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from eolaswork.webhooks import verify_signature
|
|
94
|
+
|
|
95
|
+
@app.post("/eolaswork/hook")
|
|
96
|
+
def hook(request):
|
|
97
|
+
payload = verify_signature(
|
|
98
|
+
raw_body=request.body,
|
|
99
|
+
signature_header=request.headers["X-EolasWork-Signature"],
|
|
100
|
+
secret=os.environ["EOLASWORK_WEBHOOK_SECRET"],
|
|
101
|
+
)
|
|
102
|
+
print(payload.run_id, payload.status, payload.output)
|
|
103
|
+
return "", 204
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Configuration
|
|
107
|
+
|
|
108
|
+
| Env var | Default | Meaning |
|
|
109
|
+
|---|---|---|
|
|
110
|
+
| `EOLASWORK_API_KEY` | required | Bearer key (create at /settings/api-keys) |
|
|
111
|
+
| `EOLASWORK_BASE_URL` | `https://nexa.aihq.ie` | Backend host (override for self-hosted) |
|
|
112
|
+
|
|
113
|
+
Explicit constructor args win over env vars:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
client = Client(api_key="...", base_url="https://eolaswork.your-co.com",
|
|
117
|
+
timeout=120.0, max_retries=5)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Resource surface
|
|
121
|
+
|
|
122
|
+
| Resource | What it does |
|
|
123
|
+
|---|---|
|
|
124
|
+
| `client.account` | `whoami`, `instructions`, `preferences`, `artifacts` |
|
|
125
|
+
| `client.api_keys` | list / create / revoke programmatic keys |
|
|
126
|
+
| `client.tasks` | conversations CRUD + `send_message` |
|
|
127
|
+
| `client.runs` | create / retrieve / list / cancel / `wait` / `stream` / approve / deny |
|
|
128
|
+
| `client.files` | upload / list / download / delete / `to_pdf` |
|
|
129
|
+
| `client.roles` | catalogue + file content (read-only) |
|
|
130
|
+
| `client.teams` | catalogue + file content (read-only) |
|
|
131
|
+
| `client.skills` | catalogue + file content (read-only) |
|
|
132
|
+
| `client.models` | LLM model catalogue + providers |
|
|
133
|
+
| `client.followups` | cross-conversation action items |
|
|
134
|
+
| `client.memory` | user-level KV memory |
|
|
135
|
+
|
|
136
|
+
Async variants share the exact same surface on `AsyncClient` -- every method is awaitable.
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
MIT.
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# eolaswork
|
|
2
|
+
|
|
3
|
+
Official Python SDK for the [EolasWork](https://eolaswork.com) agentic platform.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
pip install eolaswork
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Python 3.11+. Sync + async clients ship together.
|
|
12
|
+
|
|
13
|
+
## Quickstart
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from eolaswork import Client
|
|
17
|
+
|
|
18
|
+
client = Client(api_key="nxa_...") # or set EOLASWORK_API_KEY
|
|
19
|
+
|
|
20
|
+
# Discover
|
|
21
|
+
me = client.account.whoami()
|
|
22
|
+
roles = client.roles.list()
|
|
23
|
+
models = client.models.list()
|
|
24
|
+
|
|
25
|
+
# Create a task + run an agent
|
|
26
|
+
task = client.tasks.create(title="Q2 board prep")
|
|
27
|
+
client.files.upload(task.id, "./Q2_sales.xlsx")
|
|
28
|
+
|
|
29
|
+
run = client.runs.create(
|
|
30
|
+
task_id=task.id,
|
|
31
|
+
prompt="Build a board-ready summary from the Excel I attached.",
|
|
32
|
+
role="research-analyst",
|
|
33
|
+
model="claude-opus-4-7",
|
|
34
|
+
skills=["xlsx-reader"],
|
|
35
|
+
webhook_url="https://my-app.example.com/eolaswork/hook", # optional
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Three ways to wait
|
|
39
|
+
final = client.runs.wait(run.id, timeout=300) # blocks
|
|
40
|
+
# or:
|
|
41
|
+
for ev in client.runs.stream(run.id): # live SSE
|
|
42
|
+
print(ev.kind, ev.payload)
|
|
43
|
+
# or: don't wait; let the webhook arrive at your endpoint.
|
|
44
|
+
|
|
45
|
+
print(final.status, final.output)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Async
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import asyncio
|
|
52
|
+
from eolaswork import AsyncClient
|
|
53
|
+
|
|
54
|
+
async def main():
|
|
55
|
+
async with AsyncClient(api_key="nxa_...") as client:
|
|
56
|
+
task = await client.tasks.create(title="async run")
|
|
57
|
+
run = await client.runs.create(task_id=task.id, prompt="...")
|
|
58
|
+
async for ev in client.runs.astream(run.id):
|
|
59
|
+
print(ev)
|
|
60
|
+
|
|
61
|
+
asyncio.run(main())
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Webhook receiver
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from eolaswork.webhooks import verify_signature
|
|
68
|
+
|
|
69
|
+
@app.post("/eolaswork/hook")
|
|
70
|
+
def hook(request):
|
|
71
|
+
payload = verify_signature(
|
|
72
|
+
raw_body=request.body,
|
|
73
|
+
signature_header=request.headers["X-EolasWork-Signature"],
|
|
74
|
+
secret=os.environ["EOLASWORK_WEBHOOK_SECRET"],
|
|
75
|
+
)
|
|
76
|
+
print(payload.run_id, payload.status, payload.output)
|
|
77
|
+
return "", 204
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Configuration
|
|
81
|
+
|
|
82
|
+
| Env var | Default | Meaning |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| `EOLASWORK_API_KEY` | required | Bearer key (create at /settings/api-keys) |
|
|
85
|
+
| `EOLASWORK_BASE_URL` | `https://nexa.aihq.ie` | Backend host (override for self-hosted) |
|
|
86
|
+
|
|
87
|
+
Explicit constructor args win over env vars:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
client = Client(api_key="...", base_url="https://eolaswork.your-co.com",
|
|
91
|
+
timeout=120.0, max_retries=5)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Resource surface
|
|
95
|
+
|
|
96
|
+
| Resource | What it does |
|
|
97
|
+
|---|---|
|
|
98
|
+
| `client.account` | `whoami`, `instructions`, `preferences`, `artifacts` |
|
|
99
|
+
| `client.api_keys` | list / create / revoke programmatic keys |
|
|
100
|
+
| `client.tasks` | conversations CRUD + `send_message` |
|
|
101
|
+
| `client.runs` | create / retrieve / list / cancel / `wait` / `stream` / approve / deny |
|
|
102
|
+
| `client.files` | upload / list / download / delete / `to_pdf` |
|
|
103
|
+
| `client.roles` | catalogue + file content (read-only) |
|
|
104
|
+
| `client.teams` | catalogue + file content (read-only) |
|
|
105
|
+
| `client.skills` | catalogue + file content (read-only) |
|
|
106
|
+
| `client.models` | LLM model catalogue + providers |
|
|
107
|
+
| `client.followups` | cross-conversation action items |
|
|
108
|
+
| `client.memory` | user-level KV memory |
|
|
109
|
+
|
|
110
|
+
Async variants share the exact same surface on `AsyncClient` -- every method is awaitable.
|
|
111
|
+
|
|
112
|
+
## License
|
|
113
|
+
|
|
114
|
+
MIT.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "eolaswork"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python SDK for the EolasWork agentic platform"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "EolasWork" }]
|
|
13
|
+
keywords = ["eolaswork", "agents", "ai", "llm", "automation"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
# httpx provides sync+async HTTP under a single client surface; we
|
|
25
|
+
# need both modes from one library so the SyncTransport and
|
|
26
|
+
# AsyncTransport share retry/error-mapping logic.
|
|
27
|
+
"httpx>=0.27,<1.0",
|
|
28
|
+
# httpx-sse adds connect_sse / aconnect_sse for the run event
|
|
29
|
+
# stream. Pinning to 0.4+ for the v12+ httpx API compatibility.
|
|
30
|
+
"httpx-sse>=0.4,<1.0",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
dev = [
|
|
35
|
+
"pytest>=8.0",
|
|
36
|
+
"pytest-asyncio>=0.23",
|
|
37
|
+
"ruff>=0.5",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[project.urls]
|
|
41
|
+
Homepage = "https://eolaswork.com"
|
|
42
|
+
Documentation = "https://docs.eolaswork.com/sdk/python"
|
|
43
|
+
Repository = "https://github.com/eolasflow/eolaswork-python"
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.wheel]
|
|
46
|
+
packages = ["src/eolaswork"]
|
|
47
|
+
|
|
48
|
+
[tool.ruff]
|
|
49
|
+
target-version = "py311"
|
|
50
|
+
line-length = 100
|
|
51
|
+
|
|
52
|
+
[tool.pytest.ini_options]
|
|
53
|
+
# `auto` lets us write async test functions without sprinkling
|
|
54
|
+
# @pytest.mark.asyncio on every one.
|
|
55
|
+
asyncio_mode = "auto"
|
|
56
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Build + publish the eolaswork SDK to PyPI.
|
|
3
|
+
#
|
|
4
|
+
# Requires PYPI_TOKEN in the environment (project-scoped API token from
|
|
5
|
+
# https://pypi.org/manage/account/token/). Run from a clean working
|
|
6
|
+
# tree on the release tag - this script DOES NOT verify branch / tag
|
|
7
|
+
# state for you.
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# PYPI_TOKEN=pypi-... bash release.sh
|
|
11
|
+
#
|
|
12
|
+
set -euo pipefail
|
|
13
|
+
|
|
14
|
+
cd "$(dirname "$0")"
|
|
15
|
+
|
|
16
|
+
if [[ -z "${PYPI_TOKEN:-}" ]]; then
|
|
17
|
+
echo "ERROR: PYPI_TOKEN env var not set" >&2
|
|
18
|
+
exit 1
|
|
19
|
+
fi
|
|
20
|
+
|
|
21
|
+
echo "[release] cleaning dist/"
|
|
22
|
+
rm -rf dist/
|
|
23
|
+
|
|
24
|
+
echo "[release] building wheel + sdist"
|
|
25
|
+
python -m build
|
|
26
|
+
|
|
27
|
+
echo "[release] uploading to PyPI"
|
|
28
|
+
python -m twine upload \
|
|
29
|
+
--username __token__ \
|
|
30
|
+
--password "$PYPI_TOKEN" \
|
|
31
|
+
dist/*
|
|
32
|
+
|
|
33
|
+
echo "[release] uploaded: $(ls dist/)"
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""EolasWork Python SDK.
|
|
2
|
+
|
|
3
|
+
Programmatic access to the EolasWork agentic platform: create tasks
|
|
4
|
+
(conversations), launch agentic runs against roles / teams / skills,
|
|
5
|
+
upload + download files, stream events, and receive webhook callbacks
|
|
6
|
+
on terminal-state transitions.
|
|
7
|
+
|
|
8
|
+
Public surface:
|
|
9
|
+
Client / AsyncClient - entry points
|
|
10
|
+
EolasWorkError + subclasses - exception hierarchy
|
|
11
|
+
Account, Task, Run, RunEvent, - response dataclasses
|
|
12
|
+
File, Model, Role, Team, Skill,
|
|
13
|
+
Followup, MemoryEntry, ApiKey
|
|
14
|
+
|
|
15
|
+
See README.md for usage examples.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
|
|
20
|
+
from .async_client import AsyncClient
|
|
21
|
+
from .client import Client
|
|
22
|
+
from .errors import (
|
|
23
|
+
APIConnectionError,
|
|
24
|
+
APITimeoutError,
|
|
25
|
+
AuthenticationError,
|
|
26
|
+
ConflictError,
|
|
27
|
+
EolasWorkError,
|
|
28
|
+
NotFoundError,
|
|
29
|
+
PermissionDeniedError,
|
|
30
|
+
RateLimitError,
|
|
31
|
+
ServerError,
|
|
32
|
+
ValidationError,
|
|
33
|
+
)
|
|
34
|
+
from .types import (
|
|
35
|
+
Account,
|
|
36
|
+
ApiKey,
|
|
37
|
+
File,
|
|
38
|
+
Followup,
|
|
39
|
+
MemoryEntry,
|
|
40
|
+
Model,
|
|
41
|
+
Role,
|
|
42
|
+
Run,
|
|
43
|
+
RunEvent,
|
|
44
|
+
Skill,
|
|
45
|
+
Task,
|
|
46
|
+
Team,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"__version__",
|
|
51
|
+
"Client",
|
|
52
|
+
"AsyncClient",
|
|
53
|
+
# Errors
|
|
54
|
+
"EolasWorkError",
|
|
55
|
+
"AuthenticationError",
|
|
56
|
+
"PermissionDeniedError",
|
|
57
|
+
"NotFoundError",
|
|
58
|
+
"ConflictError",
|
|
59
|
+
"ValidationError",
|
|
60
|
+
"RateLimitError",
|
|
61
|
+
"ServerError",
|
|
62
|
+
"APIConnectionError",
|
|
63
|
+
"APITimeoutError",
|
|
64
|
+
# Types
|
|
65
|
+
"Account",
|
|
66
|
+
"Task",
|
|
67
|
+
"Run",
|
|
68
|
+
"RunEvent",
|
|
69
|
+
"File",
|
|
70
|
+
"Model",
|
|
71
|
+
"Role",
|
|
72
|
+
"Team",
|
|
73
|
+
"Skill",
|
|
74
|
+
"Followup",
|
|
75
|
+
"MemoryEntry",
|
|
76
|
+
"ApiKey",
|
|
77
|
+
]
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Async mirror of SyncTransport.
|
|
2
|
+
|
|
3
|
+
Shares the same _handle / status-mapping logic; differs only in
|
|
4
|
+
awaiting httpx.AsyncClient. The two transports import the shared
|
|
5
|
+
constants (RETRY_STATUSES, SAFE_VERBS, _retry_after) from _transport
|
|
6
|
+
so the two stay in lock-step.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import random
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from ._config import ClientConfig
|
|
18
|
+
from ._transport import RETRY_STATUSES, SAFE_VERBS, _retry_after
|
|
19
|
+
from .errors import (
|
|
20
|
+
APIConnectionError,
|
|
21
|
+
APITimeoutError,
|
|
22
|
+
EolasWorkError,
|
|
23
|
+
error_for_status,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AsyncTransport:
|
|
28
|
+
def __init__(self, config: ClientConfig):
|
|
29
|
+
self._config = config
|
|
30
|
+
self._client = httpx.AsyncClient(
|
|
31
|
+
base_url=config.base_url,
|
|
32
|
+
timeout=config.timeout,
|
|
33
|
+
transport=config.transport,
|
|
34
|
+
headers={
|
|
35
|
+
"user-agent": config.user_agent,
|
|
36
|
+
"authorization": f"Bearer {config.api_key}",
|
|
37
|
+
},
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
async def aclose(self) -> None:
|
|
41
|
+
await self._client.aclose()
|
|
42
|
+
|
|
43
|
+
async def __aenter__(self):
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
async def __aexit__(self, *_):
|
|
47
|
+
await self.aclose()
|
|
48
|
+
|
|
49
|
+
async def request(
|
|
50
|
+
self,
|
|
51
|
+
method: str,
|
|
52
|
+
path: str,
|
|
53
|
+
*,
|
|
54
|
+
params: dict[str, Any] | None = None,
|
|
55
|
+
json: Any = None,
|
|
56
|
+
files: Any = None,
|
|
57
|
+
data: dict[str, Any] | None = None,
|
|
58
|
+
idempotency_key: str | None = None,
|
|
59
|
+
extra_headers: dict[str, str] | None = None,
|
|
60
|
+
) -> Any:
|
|
61
|
+
headers = dict(extra_headers or {})
|
|
62
|
+
if idempotency_key:
|
|
63
|
+
headers["idempotency-key"] = idempotency_key
|
|
64
|
+
|
|
65
|
+
retry_eligible = method.upper() in SAFE_VERBS or idempotency_key is not None
|
|
66
|
+
attempts = max(self._config.max_retries + 1, 1) if retry_eligible else 1
|
|
67
|
+
|
|
68
|
+
for attempt in range(attempts):
|
|
69
|
+
try:
|
|
70
|
+
response = await self._client.request(
|
|
71
|
+
method, path, params=params, json=json, files=files,
|
|
72
|
+
data=data, headers=headers,
|
|
73
|
+
)
|
|
74
|
+
except httpx.TimeoutException as exc:
|
|
75
|
+
if attempt + 1 < attempts:
|
|
76
|
+
await self._sleep(attempt, None)
|
|
77
|
+
continue
|
|
78
|
+
raise APITimeoutError(str(exc)) from exc
|
|
79
|
+
except httpx.HTTPError as exc:
|
|
80
|
+
if attempt + 1 < attempts:
|
|
81
|
+
await self._sleep(attempt, None)
|
|
82
|
+
continue
|
|
83
|
+
raise APIConnectionError(str(exc)) from exc
|
|
84
|
+
|
|
85
|
+
if response.status_code in RETRY_STATUSES and attempt + 1 < attempts:
|
|
86
|
+
await self._sleep(attempt, _retry_after(response))
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
return self._handle(response)
|
|
90
|
+
|
|
91
|
+
raise EolasWorkError("retry loop exited without response")
|
|
92
|
+
|
|
93
|
+
async def stream(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
|
94
|
+
"""Open an async streaming response. Caller awaits + closes."""
|
|
95
|
+
req = self._client.build_request(method, path, **kwargs)
|
|
96
|
+
return await self._client.send(req, stream=True)
|
|
97
|
+
|
|
98
|
+
def _handle(self, response: httpx.Response) -> Any:
|
|
99
|
+
request_id = response.headers.get("x-request-id")
|
|
100
|
+
if response.status_code == 204 or not response.content:
|
|
101
|
+
if 200 <= response.status_code < 300:
|
|
102
|
+
return None
|
|
103
|
+
raise error_for_status(response.status_code, request_id=request_id, body=None)
|
|
104
|
+
try:
|
|
105
|
+
body = response.json()
|
|
106
|
+
except Exception:
|
|
107
|
+
body = response.text
|
|
108
|
+
if 200 <= response.status_code < 300:
|
|
109
|
+
return body
|
|
110
|
+
raise error_for_status(
|
|
111
|
+
response.status_code,
|
|
112
|
+
request_id=request_id,
|
|
113
|
+
body=body,
|
|
114
|
+
retry_after=_retry_after(response),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
async def _sleep(attempt: int, retry_after_sec: float | None) -> None:
|
|
119
|
+
if retry_after_sec is not None:
|
|
120
|
+
await asyncio.sleep(retry_after_sec)
|
|
121
|
+
return
|
|
122
|
+
base = min(0.5 * (2 ** attempt), 8.0)
|
|
123
|
+
await asyncio.sleep(base + random.uniform(0, base / 2))
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Client-wide configuration: API key, base URL, timeouts, retries.
|
|
2
|
+
|
|
3
|
+
Resolution order for every field:
|
|
4
|
+
1. Explicit constructor argument (wins)
|
|
5
|
+
2. Corresponding environment variable
|
|
6
|
+
3. Built-in default
|
|
7
|
+
|
|
8
|
+
Centralized here so both Client and AsyncClient share one canonical
|
|
9
|
+
factory and any future config key only needs to be added in one place.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from .errors import EolasWorkError
|
|
19
|
+
|
|
20
|
+
# Default base URL points at the production EolasWork host. Self-hosted
|
|
21
|
+
# / on-prem (e.g. HCL) deployments override via EOLASWORK_BASE_URL or
|
|
22
|
+
# the explicit `base_url=` arg.
|
|
23
|
+
DEFAULT_BASE_URL = "https://nexa.aihq.ie"
|
|
24
|
+
DEFAULT_TIMEOUT = 60.0
|
|
25
|
+
DEFAULT_MAX_RETRIES = 3
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ClientConfig:
|
|
30
|
+
"""Resolved config for one Client / AsyncClient instance.
|
|
31
|
+
|
|
32
|
+
Constructed eagerly so a missing API key fails fast at construction
|
|
33
|
+
time, not on the first network call. The dataclass field defaults
|
|
34
|
+
are placeholders; the __init__ override below performs real
|
|
35
|
+
env-var resolution.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
api_key: str = field(default="")
|
|
39
|
+
base_url: str = field(default="")
|
|
40
|
+
timeout: float = DEFAULT_TIMEOUT
|
|
41
|
+
max_retries: int = DEFAULT_MAX_RETRIES
|
|
42
|
+
# httpx.BaseTransport for tests. Untyped on purpose so we don't pull
|
|
43
|
+
# httpx into this module's import surface; transport modules import
|
|
44
|
+
# it themselves where needed.
|
|
45
|
+
transport: Any = None
|
|
46
|
+
user_agent: str = field(default="")
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
api_key: str | None = None,
|
|
51
|
+
base_url: str | None = None,
|
|
52
|
+
timeout: float | None = None,
|
|
53
|
+
max_retries: int | None = None,
|
|
54
|
+
transport: Any = None,
|
|
55
|
+
user_agent: str | None = None,
|
|
56
|
+
):
|
|
57
|
+
self.api_key = api_key or os.environ.get("EOLASWORK_API_KEY", "")
|
|
58
|
+
if not self.api_key:
|
|
59
|
+
raise EolasWorkError(
|
|
60
|
+
"api_key required - pass api_key= or set EOLASWORK_API_KEY"
|
|
61
|
+
)
|
|
62
|
+
# Strip any trailing slash so resource modules can confidently
|
|
63
|
+
# concatenate paths starting with `/api/...`.
|
|
64
|
+
self.base_url = (
|
|
65
|
+
base_url or os.environ.get("EOLASWORK_BASE_URL", DEFAULT_BASE_URL)
|
|
66
|
+
).rstrip("/")
|
|
67
|
+
self.timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
|
|
68
|
+
self.max_retries = max_retries if max_retries is not None else DEFAULT_MAX_RETRIES
|
|
69
|
+
self.transport = transport
|
|
70
|
+
# Defer the __version__ import to construction time so a circular
|
|
71
|
+
# import (eolaswork.__init__ -> ClientConfig -> __version__)
|
|
72
|
+
# can't fire at module load.
|
|
73
|
+
from . import __version__
|
|
74
|
+
self.user_agent = user_agent or f"eolaswork-python/{__version__}"
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""SSE iterators for the EolasWork run event stream.
|
|
2
|
+
|
|
3
|
+
The backend emits standard text/event-stream lines:
|
|
4
|
+
event: <kind>
|
|
5
|
+
data: <json>
|
|
6
|
+
(blank line)
|
|
7
|
+
id: <optional event id>
|
|
8
|
+
|
|
9
|
+
We yield RunEvent dataclasses with `kind` (the event name), `action_id`
|
|
10
|
+
extracted from the JSON payload when present, and `payload` carrying
|
|
11
|
+
the parsed JSON body of the event.
|
|
12
|
+
|
|
13
|
+
httpx-sse provides the connect_sse / aconnect_sse context managers
|
|
14
|
+
that wrap an httpx Client + request + EventSource setup; we wire
|
|
15
|
+
those to our transports' underlying clients.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from typing import AsyncIterator, Iterator
|
|
22
|
+
|
|
23
|
+
from httpx_sse import aconnect_sse, connect_sse
|
|
24
|
+
|
|
25
|
+
from ._atransport import AsyncTransport
|
|
26
|
+
from ._transport import SyncTransport
|
|
27
|
+
from .types import RunEvent
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def iter_run_events(transport: SyncTransport, run_id: str) -> Iterator[RunEvent]:
|
|
31
|
+
# connect_sse owns the underlying httpx.Client streaming response;
|
|
32
|
+
# using it as a context manager guarantees the socket is closed
|
|
33
|
+
# when the caller stops iterating (or an exception fires).
|
|
34
|
+
with connect_sse(
|
|
35
|
+
transport._client, "GET", f"/api/runs/{run_id}/stream"
|
|
36
|
+
) as event_source:
|
|
37
|
+
for sse in event_source.iter_sse():
|
|
38
|
+
yield _to_event(sse.event, sse.data)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def aiter_run_events(
|
|
42
|
+
transport: AsyncTransport, run_id: str
|
|
43
|
+
) -> AsyncIterator[RunEvent]:
|
|
44
|
+
async with aconnect_sse(
|
|
45
|
+
transport._client, "GET", f"/api/runs/{run_id}/stream"
|
|
46
|
+
) as event_source:
|
|
47
|
+
async for sse in event_source.aiter_sse():
|
|
48
|
+
yield _to_event(sse.event, sse.data)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _to_event(event_name: str | None, raw_data: str) -> RunEvent:
|
|
52
|
+
try:
|
|
53
|
+
payload = json.loads(raw_data) if raw_data else {}
|
|
54
|
+
except json.JSONDecodeError:
|
|
55
|
+
# Non-JSON data event - rare, surface verbatim under `raw` so
|
|
56
|
+
# nothing is silently dropped.
|
|
57
|
+
payload = {"raw": raw_data}
|
|
58
|
+
return RunEvent(
|
|
59
|
+
kind=event_name or "message",
|
|
60
|
+
# SSE events frequently carry the relevant action id in
|
|
61
|
+
# payload.actionId; lift it onto the dataclass as a first-class
|
|
62
|
+
# field so consumers don't have to keep digging into payload.
|
|
63
|
+
action_id=payload.get("actionId") if isinstance(payload, dict) else None,
|
|
64
|
+
payload=payload if isinstance(payload, dict) else {"raw": payload},
|
|
65
|
+
)
|