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/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"))
|