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/errors.py ADDED
@@ -0,0 +1,126 @@
1
+ """Exception hierarchy for the eolaswork SDK.
2
+
3
+ Every error raised from Client/AsyncClient is a subclass of EolasWorkError
4
+ so callers can `except EolasWorkError` to catch every SDK-originated
5
+ failure. Each leaf class corresponds to a single HTTP failure mode or
6
+ client-side problem (timeout, connection, signature mismatch).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+
14
+ class EolasWorkError(Exception):
15
+ """Base class for all SDK errors.
16
+
17
+ Carries the original HTTP status, server-issued request_id (for
18
+ support correspondence), and the parsed response body so callers
19
+ can drill in past the human-readable message when needed.
20
+ """
21
+
22
+ def __init__(
23
+ self,
24
+ message: str,
25
+ *,
26
+ status_code: int | None = None,
27
+ request_id: str | None = None,
28
+ body: Any = None,
29
+ ):
30
+ super().__init__(message)
31
+ self.status_code = status_code
32
+ self.request_id = request_id
33
+ self.body = body
34
+
35
+
36
+ class AuthenticationError(EolasWorkError):
37
+ """401 - missing, invalid, or revoked credentials."""
38
+
39
+
40
+ class PermissionDeniedError(EolasWorkError):
41
+ """403 - caller is authenticated but not authorized for the action."""
42
+
43
+
44
+ class NotFoundError(EolasWorkError):
45
+ """404 - the resource does not exist."""
46
+
47
+
48
+ class ConflictError(EolasWorkError):
49
+ """409 - the requested state conflicts with the current state."""
50
+
51
+
52
+ class ValidationError(EolasWorkError):
53
+ """400 / 422 - the request body or query params were rejected.
54
+
55
+ `.field_errors` exposes any per-field detail the server returned in
56
+ the body's `field_errors` key, so callers can build form-style UX
57
+ without re-parsing the response body themselves.
58
+ """
59
+
60
+ def __init__(self, message: str, **kwargs: Any):
61
+ super().__init__(message, **kwargs)
62
+ body = kwargs.get("body") or {}
63
+ self.field_errors: dict[str, Any] = (
64
+ body.get("field_errors", {}) if isinstance(body, dict) else {}
65
+ )
66
+
67
+
68
+ class RateLimitError(EolasWorkError):
69
+ """429 - server is throttling the caller.
70
+
71
+ `.retry_after` (seconds, float) is populated from the Retry-After
72
+ header when present so callers can build a polite backoff loop.
73
+ """
74
+
75
+ def __init__(self, message: str, *, retry_after: float | None = None, **kwargs: Any):
76
+ super().__init__(message, **kwargs)
77
+ self.retry_after = retry_after
78
+
79
+
80
+ class ServerError(EolasWorkError):
81
+ """5xx - server failure. Usually retryable, often transient."""
82
+
83
+
84
+ class APIConnectionError(EolasWorkError):
85
+ """Network / DNS / TLS failure - the request never reached the server."""
86
+
87
+
88
+ class APITimeoutError(EolasWorkError):
89
+ """Client-side timeout - the server didn't respond within `timeout=`."""
90
+
91
+
92
+ def error_for_status(
93
+ status: int,
94
+ *,
95
+ request_id: str | None,
96
+ body: Any,
97
+ retry_after: float | None = None,
98
+ ) -> EolasWorkError:
99
+ """Map an HTTP status code to the matching SDK exception type.
100
+
101
+ The transport calls this on every non-2xx response. Unknown 4xx
102
+ codes fall through to the generic EolasWorkError so the caller
103
+ still gets a useful exception with status + body attached rather
104
+ than an opaque HTTPError from httpx.
105
+ """
106
+ message = (
107
+ body.get("message")
108
+ if isinstance(body, dict) and isinstance(body.get("message"), str)
109
+ else f"HTTP {status}"
110
+ )
111
+ common = {"status_code": status, "request_id": request_id, "body": body}
112
+ if status == 401:
113
+ return AuthenticationError(message, **common)
114
+ if status == 403:
115
+ return PermissionDeniedError(message, **common)
116
+ if status == 404:
117
+ return NotFoundError(message, **common)
118
+ if status == 409:
119
+ return ConflictError(message, **common)
120
+ if status in (400, 422):
121
+ return ValidationError(message, **common)
122
+ if status == 429:
123
+ return RateLimitError(message, retry_after=retry_after, **common)
124
+ if 500 <= status < 600:
125
+ return ServerError(message, **common)
126
+ return EolasWorkError(message, **common)
@@ -0,0 +1 @@
1
+ """Resource modules, one per top-level API path family."""
@@ -0,0 +1,28 @@
1
+ """Resource base mixins.
2
+
3
+ Each resource holds one typed reference to a transport. The mixin
4
+ exists so the resource classes don't reimplement the constructor 11
5
+ times - and so a future cross-cutting concern (resource-level metrics,
6
+ logging, etc.) has one place to land.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING
12
+
13
+ if TYPE_CHECKING:
14
+ from .._atransport import AsyncTransport
15
+ from .._transport import SyncTransport
16
+
17
+
18
+ class BaseResource:
19
+ def __init__(self, transport: "SyncTransport"):
20
+ # Single-letter alias because resource methods reference it on
21
+ # every line; the shorter name keeps each method body
22
+ # comfortably under a single visual span.
23
+ self._t = transport
24
+
25
+
26
+ class BaseAsyncResource:
27
+ def __init__(self, transport: "AsyncTransport"):
28
+ self._t = transport
@@ -0,0 +1,63 @@
1
+ """Account resource - whoami, system instructions, preferences, artifacts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+ from urllib.parse import quote
7
+
8
+ from ..types import Account as AccountType
9
+ from ._base import BaseAsyncResource, BaseResource
10
+
11
+
12
+ class Account(BaseResource):
13
+ def whoami(self) -> AccountType:
14
+ body = self._t.request("GET", "/api/auth/me")
15
+ return AccountType.from_raw(body)
16
+
17
+ def get_instructions(self) -> str:
18
+ body = self._t.request("GET", "/api/me/instructions")
19
+ if isinstance(body, dict):
20
+ return body.get("text", "")
21
+ return str(body or "")
22
+
23
+ def set_instructions(self, text: str) -> None:
24
+ self._t.request("PUT", "/api/me/instructions", json={"text": text})
25
+
26
+ def get_preference(self, key: str) -> Any:
27
+ return self._t.request("GET", f"/api/me/preferences/{quote(key, safe='')}")
28
+
29
+ def set_preference(self, key: str, value: Any) -> None:
30
+ self._t.request(
31
+ "PUT", f"/api/me/preferences/{quote(key, safe='')}", json={"value": value}
32
+ )
33
+
34
+ def artifacts(self) -> list[dict[str, Any]]:
35
+ body = self._t.request("GET", "/api/me/artifacts")
36
+ return list(body) if isinstance(body, list) else body.get("items", [])
37
+
38
+
39
+ class AsyncAccount(BaseAsyncResource):
40
+ async def whoami(self) -> AccountType:
41
+ body = await self._t.request("GET", "/api/auth/me")
42
+ return AccountType.from_raw(body)
43
+
44
+ async def get_instructions(self) -> str:
45
+ body = await self._t.request("GET", "/api/me/instructions")
46
+ if isinstance(body, dict):
47
+ return body.get("text", "")
48
+ return str(body or "")
49
+
50
+ async def set_instructions(self, text: str) -> None:
51
+ await self._t.request("PUT", "/api/me/instructions", json={"text": text})
52
+
53
+ async def get_preference(self, key: str) -> Any:
54
+ return await self._t.request("GET", f"/api/me/preferences/{quote(key, safe='')}")
55
+
56
+ async def set_preference(self, key: str, value: Any) -> None:
57
+ await self._t.request(
58
+ "PUT", f"/api/me/preferences/{quote(key, safe='')}", json={"value": value}
59
+ )
60
+
61
+ async def artifacts(self) -> list[dict[str, Any]]:
62
+ body = await self._t.request("GET", "/api/me/artifacts")
63
+ return list(body) if isinstance(body, list) else body.get("items", [])
@@ -0,0 +1,45 @@
1
+ """Programmatic API key management - list, create, revoke.
2
+
3
+ Mirrors POST/GET/DELETE on /api/me/api-keys. The plaintext key is
4
+ returned on create() ONLY; users must persist it immediately because
5
+ the server stores only the argon2 hash + 8-char public prefix.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from ..types import ApiKey
11
+ from ._base import BaseAsyncResource, BaseResource
12
+
13
+
14
+ class ApiKeys(BaseResource):
15
+ def list(self) -> list[ApiKey]:
16
+ rows = self._t.request("GET", "/api/me/api-keys") or []
17
+ return [ApiKey.from_raw(r) for r in rows]
18
+
19
+ def create(self, name: str, scopes: list[str] | None = None) -> ApiKey:
20
+ body = self._t.request(
21
+ "POST",
22
+ "/api/me/api-keys",
23
+ json={"name": name, "scopes": scopes or []},
24
+ )
25
+ return ApiKey.from_raw(body)
26
+
27
+ def revoke(self, key_id: str) -> None:
28
+ self._t.request("DELETE", f"/api/me/api-keys/{key_id}")
29
+
30
+
31
+ class AsyncApiKeys(BaseAsyncResource):
32
+ async def list(self) -> list[ApiKey]:
33
+ rows = await self._t.request("GET", "/api/me/api-keys") or []
34
+ return [ApiKey.from_raw(r) for r in rows]
35
+
36
+ async def create(self, name: str, scopes: list[str] | None = None) -> ApiKey:
37
+ body = await self._t.request(
38
+ "POST",
39
+ "/api/me/api-keys",
40
+ json={"name": name, "scopes": scopes or []},
41
+ )
42
+ return ApiKey.from_raw(body)
43
+
44
+ async def revoke(self, key_id: str) -> None:
45
+ await self._t.request("DELETE", f"/api/me/api-keys/{key_id}")
@@ -0,0 +1,180 @@
1
+ """Conversation/task file storage - upload, list, download, delete, to_pdf.
2
+
3
+ 200 MB / file cap matches the multer config in
4
+ api/server/routes/conversations.js (search for `fileSize`). 25 files
5
+ per request is the server's max too. We validate caller-side so we
6
+ fail fast before sending the multipart body up the wire.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import io
12
+ from pathlib import Path
13
+ from typing import BinaryIO
14
+
15
+ from ..errors import ValidationError
16
+ from ..types import File
17
+ from ._base import BaseAsyncResource, BaseResource
18
+
19
+ MAX_FILE_BYTES = 200 * 1024 * 1024
20
+ MAX_FILES_PER_REQUEST = 25
21
+
22
+
23
+ def _coerce_to_file(
24
+ source: str | Path | bytes | BinaryIO,
25
+ filename: str | None,
26
+ content_type: str | None,
27
+ ) -> tuple[str, BinaryIO, str | None]:
28
+ """Coerce one of several source shapes into the (name, file-like, ct)
29
+ triple httpx expects for a multipart part.
30
+
31
+ Validates the byte-count for bytes/Path sources. BinaryIO sources
32
+ are not pre-checked (length unknown without seek+tell which would
33
+ consume the stream) - the server caps at MAX_FILE_BYTES regardless.
34
+ """
35
+ ct = content_type
36
+ if isinstance(source, (str, Path)):
37
+ path = Path(source)
38
+ size = path.stat().st_size
39
+ if size > MAX_FILE_BYTES:
40
+ raise ValidationError(
41
+ f"file {path.name} ({size} bytes) exceeds {MAX_FILE_BYTES}-byte limit"
42
+ )
43
+ name = filename or path.name
44
+ return (name, path.open("rb"), ct)
45
+ if isinstance(source, bytes):
46
+ if len(source) > MAX_FILE_BYTES:
47
+ raise ValidationError(
48
+ f"bytes payload ({len(source)} bytes) exceeds {MAX_FILE_BYTES}-byte limit"
49
+ )
50
+ if not filename:
51
+ raise ValidationError("filename= required when source is bytes")
52
+ return (filename, io.BytesIO(source), ct)
53
+ # Assume BinaryIO-like - any file-like object opened in binary mode.
54
+ if not filename:
55
+ raise ValidationError("filename= required when source is a file-like object")
56
+ return (filename, source, ct)
57
+
58
+
59
+ def _httpx_files_arg(
60
+ items: list[tuple[str, BinaryIO, str | None]],
61
+ ) -> list[tuple[str, tuple[str, BinaryIO, str]]]:
62
+ """Build the list-of-tuples form httpx accepts for repeated multipart
63
+ fields. The backend's multer config is `upload.array('files', 25)`
64
+ so every part uses the field name 'files'."""
65
+ if len(items) > MAX_FILES_PER_REQUEST:
66
+ raise ValidationError(
67
+ f"too many files: {len(items)} > {MAX_FILES_PER_REQUEST} limit per request"
68
+ )
69
+ return [
70
+ ("files", (name, fobj, ct or "application/octet-stream"))
71
+ for (name, fobj, ct) in items
72
+ ]
73
+
74
+
75
+ def _unwrap_files_response(body: object, fallback_name: str) -> File:
76
+ """Backend may return a single file dict, a list, or {files:[...]}.
77
+ Normalize all three shapes to the first File."""
78
+ if isinstance(body, list) and body:
79
+ return File.from_raw(body[0])
80
+ if isinstance(body, dict) and "files" in body and body["files"]:
81
+ return File.from_raw(body["files"][0])
82
+ if isinstance(body, dict) and body:
83
+ return File.from_raw(body)
84
+ return File.from_raw({"name": fallback_name, "size": 0})
85
+
86
+
87
+ class Files(BaseResource):
88
+ def list(self, task_id: str) -> list[File]:
89
+ rows = self._t.request("GET", f"/api/conversations/{task_id}/files") or []
90
+ items = rows.get("files") if isinstance(rows, dict) else rows
91
+ return [File.from_raw(r) for r in items or []]
92
+
93
+ def upload(
94
+ self,
95
+ task_id: str,
96
+ source: str | Path | bytes | BinaryIO,
97
+ *,
98
+ filename: str | None = None,
99
+ content_type: str | None = None,
100
+ ) -> File:
101
+ name, fobj, ct = _coerce_to_file(source, filename, content_type)
102
+ try:
103
+ files_arg = _httpx_files_arg([(name, fobj, ct)])
104
+ body = self._t.request(
105
+ "POST", f"/api/conversations/{task_id}/files", files=files_arg
106
+ )
107
+ finally:
108
+ try:
109
+ fobj.close()
110
+ except Exception:
111
+ pass
112
+ return _unwrap_files_response(body, name)
113
+
114
+ def download(self, task_id: str, name: str) -> bytes:
115
+ # Bypass JSON parsing via the streaming path so binary bodies
116
+ # come back as bytes without going through .json().
117
+ with self._t.stream("GET", f"/api/conversations/{task_id}/files/{name}") as resp:
118
+ resp.raise_for_status()
119
+ return resp.read()
120
+
121
+ def delete(self, task_id: str, name: str) -> None:
122
+ self._t.request("DELETE", f"/api/conversations/{task_id}/files/{name}")
123
+
124
+ def to_pdf(self, task_id: str, name: str) -> bytes:
125
+ with self._t.stream(
126
+ "GET", f"/api/conversations/{task_id}/files/{name}/pdf"
127
+ ) as resp:
128
+ resp.raise_for_status()
129
+ return resp.read()
130
+
131
+
132
+ class AsyncFiles(BaseAsyncResource):
133
+ async def list(self, task_id: str) -> list[File]:
134
+ rows = await self._t.request("GET", f"/api/conversations/{task_id}/files") or []
135
+ items = rows.get("files") if isinstance(rows, dict) else rows
136
+ return [File.from_raw(r) for r in items or []]
137
+
138
+ async def upload(
139
+ self,
140
+ task_id: str,
141
+ source: str | Path | bytes | BinaryIO,
142
+ *,
143
+ filename: str | None = None,
144
+ content_type: str | None = None,
145
+ ) -> File:
146
+ name, fobj, ct = _coerce_to_file(source, filename, content_type)
147
+ try:
148
+ files_arg = _httpx_files_arg([(name, fobj, ct)])
149
+ body = await self._t.request(
150
+ "POST", f"/api/conversations/{task_id}/files", files=files_arg
151
+ )
152
+ finally:
153
+ try:
154
+ fobj.close()
155
+ except Exception:
156
+ pass
157
+ return _unwrap_files_response(body, name)
158
+
159
+ async def download(self, task_id: str, name: str) -> bytes:
160
+ resp = await self._t.stream(
161
+ "GET", f"/api/conversations/{task_id}/files/{name}"
162
+ )
163
+ try:
164
+ resp.raise_for_status()
165
+ return await resp.aread()
166
+ finally:
167
+ await resp.aclose()
168
+
169
+ async def delete(self, task_id: str, name: str) -> None:
170
+ await self._t.request("DELETE", f"/api/conversations/{task_id}/files/{name}")
171
+
172
+ async def to_pdf(self, task_id: str, name: str) -> bytes:
173
+ resp = await self._t.stream(
174
+ "GET", f"/api/conversations/{task_id}/files/{name}/pdf"
175
+ )
176
+ try:
177
+ resp.raise_for_status()
178
+ return await resp.aread()
179
+ finally:
180
+ await resp.aclose()
@@ -0,0 +1,73 @@
1
+ """Followups - cross-conversation action items the agent flagged.
2
+
3
+ Backed by /api/followups; mirrors the SPA's Follow-ups page surface.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+ from ..types import Followup
11
+ from ._base import BaseAsyncResource, BaseResource
12
+
13
+
14
+ def _items(body: Any) -> list[dict[str, Any]]:
15
+ if isinstance(body, dict):
16
+ return list(body.get("items", []))
17
+ return list(body or [])
18
+
19
+
20
+ class Followups(BaseResource):
21
+ def list(self, projects: bool = False) -> list[Followup]:
22
+ params = {"projects": "true"} if projects else None
23
+ return [
24
+ Followup.from_raw(r)
25
+ for r in _items(self._t.request("GET", "/api/followups", params=params))
26
+ ]
27
+
28
+ def create(
29
+ self, *, text: str, due_at: str | None = None, **extra: Any
30
+ ) -> Followup:
31
+ body: dict[str, Any] = {"text": text}
32
+ if due_at:
33
+ body["dueAt"] = due_at
34
+ body.update(extra)
35
+ return Followup.from_raw(self._t.request("POST", "/api/followups", json=body))
36
+
37
+ def update(self, followup_id: str, **fields: Any) -> Followup:
38
+ return Followup.from_raw(
39
+ self._t.request("PATCH", f"/api/followups/{followup_id}", json=fields)
40
+ )
41
+
42
+ def delete(self, followup_id: str) -> None:
43
+ self._t.request("DELETE", f"/api/followups/{followup_id}")
44
+
45
+
46
+ class AsyncFollowups(BaseAsyncResource):
47
+ async def list(self, projects: bool = False) -> list[Followup]:
48
+ params = {"projects": "true"} if projects else None
49
+ return [
50
+ Followup.from_raw(r)
51
+ for r in _items(
52
+ await self._t.request("GET", "/api/followups", params=params)
53
+ )
54
+ ]
55
+
56
+ async def create(
57
+ self, *, text: str, due_at: str | None = None, **extra: Any
58
+ ) -> Followup:
59
+ body: dict[str, Any] = {"text": text}
60
+ if due_at:
61
+ body["dueAt"] = due_at
62
+ body.update(extra)
63
+ return Followup.from_raw(
64
+ await self._t.request("POST", "/api/followups", json=body)
65
+ )
66
+
67
+ async def update(self, followup_id: str, **fields: Any) -> Followup:
68
+ return Followup.from_raw(
69
+ await self._t.request("PATCH", f"/api/followups/{followup_id}", json=fields)
70
+ )
71
+
72
+ async def delete(self, followup_id: str) -> None:
73
+ await self._t.request("DELETE", f"/api/followups/{followup_id}")
@@ -0,0 +1,74 @@
1
+ """User-level memory key-value store backed by /api/memories.
2
+
3
+ The backend has no per-key GET endpoint; SDK callers wanting a single
4
+ entry should call .list() and filter (entries are small).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from ..types import MemoryEntry
12
+ from ._base import BaseAsyncResource, BaseResource
13
+
14
+
15
+ def _items(body: Any) -> list[dict[str, Any]]:
16
+ if isinstance(body, dict):
17
+ return list(body.get("items", []))
18
+ return list(body or [])
19
+
20
+
21
+ class Memory(BaseResource):
22
+ def list(self) -> list[MemoryEntry]:
23
+ return [
24
+ MemoryEntry.from_raw(r)
25
+ for r in _items(self._t.request("GET", "/api/memories"))
26
+ ]
27
+
28
+ def put(self, key: str, value: str) -> MemoryEntry:
29
+ return MemoryEntry.from_raw(
30
+ self._t.request("POST", "/api/memories", json={"key": key, "value": value})
31
+ )
32
+
33
+ def update(self, key: str, value: str) -> MemoryEntry:
34
+ return MemoryEntry.from_raw(
35
+ self._t.request("PATCH", f"/api/memories/{key}", json={"value": value})
36
+ )
37
+
38
+ def delete(self, key: str) -> None:
39
+ self._t.request("DELETE", f"/api/memories/{key}")
40
+
41
+ def set_opt_out(self, enabled: bool) -> None:
42
+ self._t.request(
43
+ "PATCH", "/api/memories/preferences", json={"optOut": enabled}
44
+ )
45
+
46
+
47
+ class AsyncMemory(BaseAsyncResource):
48
+ async def list(self) -> list[MemoryEntry]:
49
+ return [
50
+ MemoryEntry.from_raw(r)
51
+ for r in _items(await self._t.request("GET", "/api/memories"))
52
+ ]
53
+
54
+ async def put(self, key: str, value: str) -> MemoryEntry:
55
+ return MemoryEntry.from_raw(
56
+ await self._t.request(
57
+ "POST", "/api/memories", json={"key": key, "value": value}
58
+ )
59
+ )
60
+
61
+ async def update(self, key: str, value: str) -> MemoryEntry:
62
+ return MemoryEntry.from_raw(
63
+ await self._t.request(
64
+ "PATCH", f"/api/memories/{key}", json={"value": value}
65
+ )
66
+ )
67
+
68
+ async def delete(self, key: str) -> None:
69
+ await self._t.request("DELETE", f"/api/memories/{key}")
70
+
71
+ async def set_opt_out(self, enabled: bool) -> None:
72
+ await self._t.request(
73
+ "PATCH", "/api/memories/preferences", json={"optOut": enabled}
74
+ )
@@ -0,0 +1,35 @@
1
+ """Models + providers catalogue. Read-only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ..types import Model
8
+ from ._base import BaseAsyncResource, BaseResource
9
+
10
+
11
+ def _items(body: Any) -> list[dict[str, Any]]:
12
+ if isinstance(body, dict):
13
+ return list(body.get("items", []))
14
+ return list(body or [])
15
+
16
+
17
+ class Models(BaseResource):
18
+ def list(self) -> list[Model]:
19
+ return [
20
+ Model.from_raw(r) for r in _items(self._t.request("GET", "/api/llm-models"))
21
+ ]
22
+
23
+ def providers(self) -> list[dict[str, Any]]:
24
+ return _items(self._t.request("GET", "/api/providers"))
25
+
26
+
27
+ class AsyncModels(BaseAsyncResource):
28
+ async def list(self) -> list[Model]:
29
+ return [
30
+ Model.from_raw(r)
31
+ for r in _items(await self._t.request("GET", "/api/llm-models"))
32
+ ]
33
+
34
+ async def providers(self) -> list[dict[str, Any]]:
35
+ return _items(await self._t.request("GET", "/api/providers"))