eolaswork 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- eolaswork/__init__.py +77 -0
- eolaswork/_atransport.py +123 -0
- eolaswork/_config.py +74 -0
- eolaswork/_streaming.py +65 -0
- eolaswork/_transport.py +175 -0
- eolaswork/async_client.py +75 -0
- eolaswork/client.py +77 -0
- eolaswork/errors.py +126 -0
- eolaswork/resources/__init__.py +1 -0
- eolaswork/resources/_base.py +28 -0
- eolaswork/resources/account.py +63 -0
- eolaswork/resources/api_keys.py +45 -0
- eolaswork/resources/files.py +180 -0
- eolaswork/resources/followups.py +73 -0
- eolaswork/resources/memory.py +74 -0
- eolaswork/resources/models.py +35 -0
- eolaswork/resources/roles.py +75 -0
- eolaswork/resources/runs.py +217 -0
- eolaswork/resources/skills.py +60 -0
- eolaswork/resources/tasks.py +156 -0
- eolaswork/resources/teams.py +67 -0
- eolaswork/types.py +289 -0
- eolaswork/webhooks.py +121 -0
- eolaswork-0.1.0.dist-info/METADATA +140 -0
- eolaswork-0.1.0.dist-info/RECORD +26 -0
- eolaswork-0.1.0.dist-info/WHEEL +4 -0
eolaswork/__init__.py
ADDED
|
@@ -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
|
+
]
|
eolaswork/_atransport.py
ADDED
|
@@ -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))
|
eolaswork/_config.py
ADDED
|
@@ -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__}"
|
eolaswork/_streaming.py
ADDED
|
@@ -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
|
+
)
|
eolaswork/_transport.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Synchronous HTTP transport for the eolaswork SDK.
|
|
2
|
+
|
|
3
|
+
Wraps httpx.Client to apply auth, JSON encoding, error mapping, and
|
|
4
|
+
retries. Resources never see httpx.Response directly - they call
|
|
5
|
+
`transport.request()` and get either parsed JSON or None (for 204s)
|
|
6
|
+
back, with exceptions raised for any non-2xx status.
|
|
7
|
+
|
|
8
|
+
Streaming: `transport.stream()` returns a raw httpx.Response with
|
|
9
|
+
`stream=True` for SSE/binary use. The streaming module + files
|
|
10
|
+
resource own it from there.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import random
|
|
16
|
+
import time
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from ._config import ClientConfig
|
|
22
|
+
from .errors import (
|
|
23
|
+
APIConnectionError,
|
|
24
|
+
APITimeoutError,
|
|
25
|
+
EolasWorkError,
|
|
26
|
+
error_for_status,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Status codes worth retrying when the request is safe to repeat. 429
|
|
30
|
+
# and the transient 5xx classes only. Connection errors are retried
|
|
31
|
+
# unconditionally (when retry-eligible).
|
|
32
|
+
RETRY_STATUSES = {429, 502, 503, 504}
|
|
33
|
+
|
|
34
|
+
# HTTP verbs we treat as retry-safe by default. POST is included ONLY
|
|
35
|
+
# when the caller passed an Idempotency-Key (resources do this for
|
|
36
|
+
# runs.create so a network blip doesn't spawn a duplicate run).
|
|
37
|
+
SAFE_VERBS = {"GET", "DELETE", "PUT", "HEAD"}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SyncTransport:
|
|
41
|
+
"""Thin sync wrapper around httpx.Client.
|
|
42
|
+
|
|
43
|
+
Designed to be one-per-Client so the auth header and base URL are
|
|
44
|
+
bound once on construction. Close via .close() or use as a context
|
|
45
|
+
manager.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, config: ClientConfig):
|
|
49
|
+
self._config = config
|
|
50
|
+
self._client = httpx.Client(
|
|
51
|
+
base_url=config.base_url,
|
|
52
|
+
timeout=config.timeout,
|
|
53
|
+
transport=config.transport,
|
|
54
|
+
headers={
|
|
55
|
+
"user-agent": config.user_agent,
|
|
56
|
+
"authorization": f"Bearer {config.api_key}",
|
|
57
|
+
},
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def close(self) -> None:
|
|
61
|
+
self._client.close()
|
|
62
|
+
|
|
63
|
+
def __enter__(self):
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
def __exit__(self, *_):
|
|
67
|
+
self.close()
|
|
68
|
+
|
|
69
|
+
def request(
|
|
70
|
+
self,
|
|
71
|
+
method: str,
|
|
72
|
+
path: str,
|
|
73
|
+
*,
|
|
74
|
+
params: dict[str, Any] | None = None,
|
|
75
|
+
json: Any = None,
|
|
76
|
+
files: Any = None,
|
|
77
|
+
data: dict[str, Any] | None = None,
|
|
78
|
+
idempotency_key: str | None = None,
|
|
79
|
+
extra_headers: dict[str, str] | None = None,
|
|
80
|
+
) -> Any:
|
|
81
|
+
"""Make a request and return parsed JSON / None / raise on error."""
|
|
82
|
+
headers = dict(extra_headers or {})
|
|
83
|
+
if idempotency_key:
|
|
84
|
+
headers["idempotency-key"] = idempotency_key
|
|
85
|
+
|
|
86
|
+
retry_eligible = method.upper() in SAFE_VERBS or idempotency_key is not None
|
|
87
|
+
attempts = max(self._config.max_retries + 1, 1) if retry_eligible else 1
|
|
88
|
+
|
|
89
|
+
for attempt in range(attempts):
|
|
90
|
+
try:
|
|
91
|
+
response = self._client.request(
|
|
92
|
+
method, path, params=params, json=json, files=files,
|
|
93
|
+
data=data, headers=headers,
|
|
94
|
+
)
|
|
95
|
+
except httpx.TimeoutException as exc:
|
|
96
|
+
if attempt + 1 < attempts:
|
|
97
|
+
self._sleep_backoff(attempt, None)
|
|
98
|
+
continue
|
|
99
|
+
raise APITimeoutError(str(exc)) from exc
|
|
100
|
+
except httpx.HTTPError as exc:
|
|
101
|
+
if attempt + 1 < attempts:
|
|
102
|
+
self._sleep_backoff(attempt, None)
|
|
103
|
+
continue
|
|
104
|
+
raise APIConnectionError(str(exc)) from exc
|
|
105
|
+
|
|
106
|
+
if response.status_code in RETRY_STATUSES and attempt + 1 < attempts:
|
|
107
|
+
self._sleep_backoff(attempt, _retry_after(response))
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
return self._handle(response)
|
|
111
|
+
|
|
112
|
+
# Unreachable - the loop returns or raises every iteration. The
|
|
113
|
+
# raise here exists for type-checker satisfaction.
|
|
114
|
+
raise EolasWorkError("retry loop exited without response")
|
|
115
|
+
|
|
116
|
+
def stream(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
|
117
|
+
"""Open a streaming response without consuming the body.
|
|
118
|
+
|
|
119
|
+
Caller is responsible for closing it (or using a context manager).
|
|
120
|
+
Used by the SSE iterator and the files resource's download path.
|
|
121
|
+
Streaming is single-shot (no retry) - SSE has its own reconnect
|
|
122
|
+
logic in _streaming.py.
|
|
123
|
+
"""
|
|
124
|
+
req = self._client.build_request(method, path, **kwargs)
|
|
125
|
+
return self._client.send(req, stream=True)
|
|
126
|
+
|
|
127
|
+
def _handle(self, response: httpx.Response) -> Any:
|
|
128
|
+
request_id = response.headers.get("x-request-id")
|
|
129
|
+
if response.status_code == 204 or not response.content:
|
|
130
|
+
if 200 <= response.status_code < 300:
|
|
131
|
+
return None
|
|
132
|
+
raise error_for_status(response.status_code, request_id=request_id, body=None)
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
body = response.json()
|
|
136
|
+
except Exception:
|
|
137
|
+
# Non-JSON body (e.g. binary download accidentally routed
|
|
138
|
+
# through .request()) - hand back the text so error_for_status
|
|
139
|
+
# at least has something.
|
|
140
|
+
body = response.text
|
|
141
|
+
|
|
142
|
+
if 200 <= response.status_code < 300:
|
|
143
|
+
return body
|
|
144
|
+
raise error_for_status(
|
|
145
|
+
response.status_code,
|
|
146
|
+
request_id=request_id,
|
|
147
|
+
body=body,
|
|
148
|
+
retry_after=_retry_after(response),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
@staticmethod
|
|
152
|
+
def _sleep_backoff(attempt: int, retry_after_sec: float | None) -> None:
|
|
153
|
+
if retry_after_sec is not None:
|
|
154
|
+
time.sleep(retry_after_sec)
|
|
155
|
+
return
|
|
156
|
+
# Exponential with jitter: 0.5s, 1s, 2s, ... capped at 8s.
|
|
157
|
+
# Jitter avoids thundering-herd on coordinated retry from many
|
|
158
|
+
# clients after a service blip.
|
|
159
|
+
base = min(0.5 * (2 ** attempt), 8.0)
|
|
160
|
+
time.sleep(base + random.uniform(0, base / 2))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _retry_after(response: httpx.Response) -> float | None:
|
|
164
|
+
"""Parse the Retry-After header value as seconds.
|
|
165
|
+
|
|
166
|
+
Accepts the integer-seconds form only; HTTP-date is rare in
|
|
167
|
+
practice and supporting it just adds parsing surface area.
|
|
168
|
+
"""
|
|
169
|
+
raw = response.headers.get("retry-after")
|
|
170
|
+
if not raw:
|
|
171
|
+
return None
|
|
172
|
+
try:
|
|
173
|
+
return float(raw)
|
|
174
|
+
except ValueError:
|
|
175
|
+
return None
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Async mirror of Client.
|
|
2
|
+
|
|
3
|
+
Same surface as the sync Client but every method is awaitable. Use
|
|
4
|
+
inside FastAPI / aiohttp / any asyncio agent framework.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
import asyncio
|
|
8
|
+
from eolaswork import AsyncClient
|
|
9
|
+
|
|
10
|
+
async def main():
|
|
11
|
+
async with AsyncClient(api_key="nxa_...") as client:
|
|
12
|
+
task = await client.tasks.create(title="async run")
|
|
13
|
+
run = await client.runs.create(task_id=task.id, prompt="hi")
|
|
14
|
+
async for event in client.runs.astream(run.id):
|
|
15
|
+
print(event)
|
|
16
|
+
|
|
17
|
+
asyncio.run(main())
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from ._atransport import AsyncTransport
|
|
25
|
+
from ._config import ClientConfig
|
|
26
|
+
from .resources.account import AsyncAccount
|
|
27
|
+
from .resources.api_keys import AsyncApiKeys
|
|
28
|
+
from .resources.files import AsyncFiles
|
|
29
|
+
from .resources.followups import AsyncFollowups
|
|
30
|
+
from .resources.memory import AsyncMemory
|
|
31
|
+
from .resources.models import AsyncModels
|
|
32
|
+
from .resources.roles import AsyncRoles
|
|
33
|
+
from .resources.runs import AsyncRuns
|
|
34
|
+
from .resources.skills import AsyncSkills
|
|
35
|
+
from .resources.tasks import AsyncTasks
|
|
36
|
+
from .resources.teams import AsyncTeams
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AsyncClient:
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
api_key: str | None = None,
|
|
43
|
+
base_url: str | None = None,
|
|
44
|
+
timeout: float | None = None,
|
|
45
|
+
max_retries: int | None = None,
|
|
46
|
+
transport: Any = None,
|
|
47
|
+
):
|
|
48
|
+
self._config = ClientConfig(
|
|
49
|
+
api_key=api_key,
|
|
50
|
+
base_url=base_url,
|
|
51
|
+
timeout=timeout,
|
|
52
|
+
max_retries=max_retries,
|
|
53
|
+
transport=transport,
|
|
54
|
+
)
|
|
55
|
+
self._transport = AsyncTransport(self._config)
|
|
56
|
+
self.account = AsyncAccount(self._transport)
|
|
57
|
+
self.api_keys = AsyncApiKeys(self._transport)
|
|
58
|
+
self.tasks = AsyncTasks(self._transport)
|
|
59
|
+
self.runs = AsyncRuns(self._transport)
|
|
60
|
+
self.files = AsyncFiles(self._transport)
|
|
61
|
+
self.roles = AsyncRoles(self._transport)
|
|
62
|
+
self.teams = AsyncTeams(self._transport)
|
|
63
|
+
self.skills = AsyncSkills(self._transport)
|
|
64
|
+
self.models = AsyncModels(self._transport)
|
|
65
|
+
self.followups = AsyncFollowups(self._transport)
|
|
66
|
+
self.memory = AsyncMemory(self._transport)
|
|
67
|
+
|
|
68
|
+
async def aclose(self) -> None:
|
|
69
|
+
await self._transport.aclose()
|
|
70
|
+
|
|
71
|
+
async def __aenter__(self):
|
|
72
|
+
return self
|
|
73
|
+
|
|
74
|
+
async def __aexit__(self, *_):
|
|
75
|
+
await self.aclose()
|
eolaswork/client.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Synchronous Client facade.
|
|
2
|
+
|
|
3
|
+
Constructed once per process / call site. Holds the resolved
|
|
4
|
+
ClientConfig, the SyncTransport, and one resource instance per
|
|
5
|
+
top-level API path family. Close via .close() or use as a context
|
|
6
|
+
manager.
|
|
7
|
+
|
|
8
|
+
Example:
|
|
9
|
+
from eolaswork import Client
|
|
10
|
+
|
|
11
|
+
client = Client(api_key="nxa_...")
|
|
12
|
+
me = client.account.whoami()
|
|
13
|
+
task = client.tasks.create(title="Q2 board prep")
|
|
14
|
+
run = client.runs.create(task_id=task.id, prompt="...", role="analyst")
|
|
15
|
+
final = client.runs.wait(run.id)
|
|
16
|
+
print(final.status, final.output)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from ._config import ClientConfig
|
|
24
|
+
from ._transport import SyncTransport
|
|
25
|
+
from .resources.account import Account
|
|
26
|
+
from .resources.api_keys import ApiKeys
|
|
27
|
+
from .resources.files import Files
|
|
28
|
+
from .resources.followups import Followups
|
|
29
|
+
from .resources.memory import Memory
|
|
30
|
+
from .resources.models import Models
|
|
31
|
+
from .resources.roles import Roles
|
|
32
|
+
from .resources.runs import Runs
|
|
33
|
+
from .resources.skills import Skills
|
|
34
|
+
from .resources.tasks import Tasks
|
|
35
|
+
from .resources.teams import Teams
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Client:
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
api_key: str | None = None,
|
|
42
|
+
base_url: str | None = None,
|
|
43
|
+
timeout: float | None = None,
|
|
44
|
+
max_retries: int | None = None,
|
|
45
|
+
transport: Any = None,
|
|
46
|
+
):
|
|
47
|
+
self._config = ClientConfig(
|
|
48
|
+
api_key=api_key,
|
|
49
|
+
base_url=base_url,
|
|
50
|
+
timeout=timeout,
|
|
51
|
+
max_retries=max_retries,
|
|
52
|
+
transport=transport,
|
|
53
|
+
)
|
|
54
|
+
self._transport = SyncTransport(self._config)
|
|
55
|
+
# All 11 resources hang off the client. Constructing them is
|
|
56
|
+
# cheap (one assignment); doing so up front means the dot-path
|
|
57
|
+
# surface is fully discoverable in any IDE / REPL.
|
|
58
|
+
self.account = Account(self._transport)
|
|
59
|
+
self.api_keys = ApiKeys(self._transport)
|
|
60
|
+
self.tasks = Tasks(self._transport)
|
|
61
|
+
self.runs = Runs(self._transport)
|
|
62
|
+
self.files = Files(self._transport)
|
|
63
|
+
self.roles = Roles(self._transport)
|
|
64
|
+
self.teams = Teams(self._transport)
|
|
65
|
+
self.skills = Skills(self._transport)
|
|
66
|
+
self.models = Models(self._transport)
|
|
67
|
+
self.followups = Followups(self._transport)
|
|
68
|
+
self.memory = Memory(self._transport)
|
|
69
|
+
|
|
70
|
+
def close(self) -> None:
|
|
71
|
+
self._transport.close()
|
|
72
|
+
|
|
73
|
+
def __enter__(self):
|
|
74
|
+
return self
|
|
75
|
+
|
|
76
|
+
def __exit__(self, *_):
|
|
77
|
+
self.close()
|