modulex-python 0.1.0__py3-none-any.whl → 1.0.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.
- modulex/__init__.py +12 -2
- modulex/_base.py +111 -16
- modulex/_client.py +25 -13
- modulex/_exceptions.py +172 -8
- modulex/_streaming.py +90 -60
- modulex/_version.py +5 -0
- modulex/resources/api_keys.py +16 -8
- modulex/resources/assistant.py +154 -0
- modulex/resources/auth.py +24 -12
- modulex/resources/chats.py +31 -14
- modulex/resources/composer.py +160 -53
- modulex/resources/credentials.py +144 -38
- modulex/resources/dashboard.py +36 -19
- modulex/resources/deployments.py +46 -26
- modulex/resources/executions.py +132 -22
- modulex/resources/integrations.py +36 -18
- modulex/resources/knowledge.py +104 -62
- modulex/resources/notifications.py +10 -4
- modulex/resources/organizations.py +122 -28
- modulex/resources/schedules.py +63 -32
- modulex/resources/subscriptions.py +32 -14
- modulex/resources/system.py +13 -11
- modulex/resources/workflows.py +53 -27
- modulex/types/__init__.py +347 -83
- modulex/types/_models.py +79 -0
- modulex/types/api_keys.py +59 -12
- modulex/types/assistant.py +104 -0
- modulex/types/auth.py +88 -43
- modulex/types/chats.py +65 -37
- modulex/types/composer.py +143 -18
- modulex/types/credentials.py +95 -54
- modulex/types/dashboard.py +247 -46
- modulex/types/deployments.py +126 -0
- modulex/types/executions.py +132 -101
- modulex/types/integrations.py +113 -21
- modulex/types/knowledge.py +143 -50
- modulex/types/notifications.py +70 -9
- modulex/types/organizations.py +131 -27
- modulex/types/realtime.py +270 -0
- modulex/types/schedules.py +88 -35
- modulex/types/subscriptions.py +91 -36
- modulex/types/system.py +62 -0
- modulex/types/workflows.py +276 -168
- {modulex_python-0.1.0.dist-info → modulex_python-1.0.0.dist-info}/METADATA +90 -51
- modulex_python-1.0.0.dist-info/RECORD +51 -0
- {modulex_python-0.1.0.dist-info → modulex_python-1.0.0.dist-info}/WHEEL +1 -1
- modulex/_compat.py +0 -39
- modulex/resources/templates.py +0 -115
- modulex/types/templates.py +0 -50
- modulex_python-0.1.0.dist-info/RECORD +0 -47
- {modulex_python-0.1.0.dist-info → modulex_python-1.0.0.dist-info}/licenses/LICENSE +0 -0
modulex/_streaming.py
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
|
-
"""SSE streaming support for the ModuleX SDK.
|
|
1
|
+
"""SSE streaming support for the ModuleX SDK.
|
|
2
|
+
|
|
3
|
+
The ModuleX backend uses **two** SSE wire conventions:
|
|
4
|
+
|
|
5
|
+
A) ``/chats/stream`` emits a real ``event:`` line (event name in the SSE field).
|
|
6
|
+
B) workflow / composer / assistant / credential streams emit raw ``data: {...}``
|
|
7
|
+
with NO ``event:`` line — the real event type lives in ``data["type"]``.
|
|
8
|
+
|
|
9
|
+
``EventSourceStream`` normalizes both: ``SSEEvent.event`` always carries the
|
|
10
|
+
logical event name, whether it came from the SSE ``event:`` field or from
|
|
11
|
+
``data["type"]``. This is what makes ``executions.listen()`` / ``composer.listen()``
|
|
12
|
+
dispatch correctly instead of seeing every event as ``"message"``.
|
|
13
|
+
"""
|
|
2
14
|
|
|
3
15
|
from __future__ import annotations
|
|
4
16
|
|
|
@@ -10,103 +22,121 @@ from typing import Any
|
|
|
10
22
|
import httpx
|
|
11
23
|
from httpx_sse import aconnect_sse
|
|
12
24
|
|
|
13
|
-
from modulex._exceptions import StreamError
|
|
25
|
+
from modulex._exceptions import ModulexError, StreamError, raise_for_status
|
|
26
|
+
|
|
27
|
+
#: Event types that terminate a run stream — iteration stops after one is seen.
|
|
28
|
+
TERMINAL_EVENT_TYPES = frozenset({"done", "error", "cancelled", "interrupted"})
|
|
29
|
+
|
|
30
|
+
#: Event types treated as keepalive noise and filtered out by default.
|
|
31
|
+
HEARTBEAT_EVENT_TYPES = frozenset({"heartbeat", "keepalive"})
|
|
14
32
|
|
|
15
33
|
|
|
16
34
|
@dataclass
|
|
17
35
|
class SSEEvent:
|
|
18
|
-
"""A Server-Sent Event.
|
|
36
|
+
"""A Server-Sent Event with a normalized logical event name.
|
|
37
|
+
|
|
38
|
+
``event`` is the logical name (from the SSE ``event:`` field for A-group
|
|
39
|
+
streams, or from ``data["type"]`` for B-group streams). ``raw_event`` keeps
|
|
40
|
+
the original SSE ``event:`` field for debugging.
|
|
41
|
+
"""
|
|
19
42
|
|
|
20
43
|
event: str
|
|
21
44
|
data: dict[str, Any]
|
|
22
45
|
id: str | None = None
|
|
23
46
|
retry: int | None = None
|
|
47
|
+
raw_event: str | None = None
|
|
24
48
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def __init__(self, response: httpx.Response) -> None:
|
|
30
|
-
self._response = response
|
|
31
|
-
self._closed = False
|
|
32
|
-
|
|
33
|
-
def __aiter__(self) -> AsyncIterator[SSEEvent]:
|
|
34
|
-
return self._iterate()
|
|
35
|
-
|
|
36
|
-
async def _iterate(self) -> AsyncIterator[SSEEvent]:
|
|
37
|
-
"""Iterate over SSE events from the response."""
|
|
38
|
-
try:
|
|
39
|
-
async for line in self._response.aiter_lines():
|
|
40
|
-
if self._closed:
|
|
41
|
-
break
|
|
42
|
-
|
|
43
|
-
line = line.strip()
|
|
44
|
-
if not line or line.startswith(":"):
|
|
45
|
-
continue
|
|
46
|
-
|
|
47
|
-
event = self._parse_event(line)
|
|
48
|
-
if event and event.event != "keepalive":
|
|
49
|
-
yield event
|
|
50
|
-
except httpx.StreamClosed:
|
|
51
|
-
return
|
|
52
|
-
except Exception as e:
|
|
53
|
-
if not self._closed:
|
|
54
|
-
raise StreamError(f"SSE stream error: {e}") from e
|
|
55
|
-
|
|
56
|
-
@staticmethod
|
|
57
|
-
def _parse_event(line: str) -> SSEEvent | None:
|
|
58
|
-
"""Parse a single SSE line into an event."""
|
|
59
|
-
if line.startswith("data:"):
|
|
60
|
-
data_str = line[5:].strip()
|
|
61
|
-
try:
|
|
62
|
-
data = json.loads(data_str)
|
|
63
|
-
except json.JSONDecodeError:
|
|
64
|
-
data = {"raw": data_str}
|
|
65
|
-
return SSEEvent(event="message", data=data)
|
|
66
|
-
return None
|
|
67
|
-
|
|
68
|
-
async def close(self) -> None:
|
|
69
|
-
"""Close the SSE stream."""
|
|
70
|
-
self._closed = True
|
|
71
|
-
await self._response.aclose()
|
|
49
|
+
@property
|
|
50
|
+
def is_terminal(self) -> bool:
|
|
51
|
+
"""Whether this event terminates the stream (done/error/cancelled/interrupted)."""
|
|
52
|
+
return self.event in TERMINAL_EVENT_TYPES
|
|
72
53
|
|
|
73
54
|
|
|
74
55
|
class EventSourceStream:
|
|
75
|
-
"""
|
|
76
|
-
|
|
77
|
-
|
|
56
|
+
"""Async iterator over a Server-Sent Events stream.
|
|
57
|
+
|
|
58
|
+
Normalizes the two backend wire conventions, surfaces HTTP errors on connect
|
|
59
|
+
as typed exceptions, filters heartbeats, and stops after a terminal event.
|
|
60
|
+
Usable as an async iterator or async context manager::
|
|
61
|
+
|
|
62
|
+
async with client.executions.listen(run_id) as stream:
|
|
63
|
+
async for event in stream:
|
|
64
|
+
...
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
client: httpx.AsyncClient,
|
|
70
|
+
method: str,
|
|
71
|
+
url: str,
|
|
72
|
+
*,
|
|
73
|
+
include_heartbeats: bool = False,
|
|
74
|
+
**kwargs: Any,
|
|
75
|
+
) -> None:
|
|
78
76
|
self._client = client
|
|
79
77
|
self._method = method
|
|
80
78
|
self._url = url
|
|
79
|
+
self._include_heartbeats = include_heartbeats
|
|
81
80
|
self._kwargs = kwargs
|
|
82
81
|
self._closed = False
|
|
82
|
+
self.last_event_id: str | None = None
|
|
83
83
|
|
|
84
84
|
def __aiter__(self) -> AsyncIterator[SSEEvent]:
|
|
85
85
|
return self._iterate()
|
|
86
86
|
|
|
87
|
+
async def __aenter__(self) -> EventSourceStream:
|
|
88
|
+
return self
|
|
89
|
+
|
|
90
|
+
async def __aexit__(self, *args: object) -> None:
|
|
91
|
+
await self.close()
|
|
92
|
+
|
|
87
93
|
async def _iterate(self) -> AsyncIterator[SSEEvent]:
|
|
88
94
|
"""Connect and iterate over SSE events."""
|
|
89
95
|
try:
|
|
90
96
|
async with aconnect_sse(self._client, self._method, self._url, **self._kwargs) as event_source:
|
|
97
|
+
response = event_source.response
|
|
98
|
+
if response.status_code >= 400:
|
|
99
|
+
# Surface auth/billing/rate denials as typed exceptions instead
|
|
100
|
+
# of an opaque "content type is not text/event-stream" SSEError.
|
|
101
|
+
await response.aread()
|
|
102
|
+
raise_for_status(response)
|
|
103
|
+
|
|
91
104
|
async for sse in event_source.aiter_sse():
|
|
92
105
|
if self._closed:
|
|
93
106
|
break
|
|
94
107
|
|
|
95
|
-
|
|
96
|
-
if event_type == "keepalive":
|
|
97
|
-
continue
|
|
98
|
-
|
|
108
|
+
raw_event = sse.event or None
|
|
99
109
|
try:
|
|
100
110
|
data = json.loads(sse.data) if sse.data else {}
|
|
101
111
|
except json.JSONDecodeError:
|
|
102
112
|
data = {"raw": sse.data}
|
|
103
113
|
|
|
114
|
+
# Normalize: prefer data["type"] (B-group), fall back to the
|
|
115
|
+
# SSE event: field (A-group). "message" is httpx_sse's default
|
|
116
|
+
# when no event: line is present, so treat it as "unknown".
|
|
117
|
+
type_in_data = data.get("type") if isinstance(data, dict) else None
|
|
118
|
+
event_name = type_in_data or (raw_event if raw_event and raw_event != "message" else "message")
|
|
119
|
+
|
|
120
|
+
if event_name in HEARTBEAT_EVENT_TYPES and not self._include_heartbeats:
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
if sse.id:
|
|
124
|
+
self.last_event_id = sse.id
|
|
125
|
+
|
|
104
126
|
yield SSEEvent(
|
|
105
|
-
event=
|
|
106
|
-
data=data,
|
|
127
|
+
event=event_name,
|
|
128
|
+
data=data if isinstance(data, dict) else {"raw": data},
|
|
107
129
|
id=sse.id,
|
|
108
130
|
retry=sse.retry,
|
|
131
|
+
raw_event=raw_event,
|
|
109
132
|
)
|
|
133
|
+
|
|
134
|
+
if event_name in TERMINAL_EVENT_TYPES:
|
|
135
|
+
break
|
|
136
|
+
except ModulexError:
|
|
137
|
+
# Typed SDK errors (auth/billing/rate from raise_for_status, StreamError)
|
|
138
|
+
# propagate as-is instead of being re-wrapped below.
|
|
139
|
+
raise
|
|
110
140
|
except httpx.StreamClosed:
|
|
111
141
|
return
|
|
112
142
|
except Exception as e:
|
|
@@ -114,5 +144,5 @@ class EventSourceStream:
|
|
|
114
144
|
raise StreamError(f"SSE stream error: {e}") from e
|
|
115
145
|
|
|
116
146
|
async def close(self) -> None:
|
|
117
|
-
"""
|
|
147
|
+
"""Stop iterating; the underlying connection closes on context exit."""
|
|
118
148
|
self._closed = True
|
modulex/_version.py
ADDED
modulex/resources/api_keys.py
CHANGED
|
@@ -5,6 +5,12 @@ from __future__ import annotations
|
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
7
|
from modulex._base import _BaseResource
|
|
8
|
+
from modulex.types.api_keys import (
|
|
9
|
+
ApiKeyListResponse,
|
|
10
|
+
ApiKeyResponse,
|
|
11
|
+
CreateApiKeyResponse,
|
|
12
|
+
RevokeApiKeyResponse,
|
|
13
|
+
)
|
|
8
14
|
|
|
9
15
|
|
|
10
16
|
class ApiKeys(_BaseResource):
|
|
@@ -17,23 +23,25 @@ class ApiKeys(_BaseResource):
|
|
|
17
23
|
organization_id: str | None = None,
|
|
18
24
|
expires_at: str | None = None,
|
|
19
25
|
rate_limit_per_minute: int = 60,
|
|
20
|
-
) ->
|
|
26
|
+
) -> CreateApiKeyResponse:
|
|
21
27
|
"""Create a new API key with the given name and options."""
|
|
22
28
|
body: dict[str, Any] = {"name": name, "rate_limit_per_minute": rate_limit_per_minute}
|
|
23
29
|
if organization_id is not None:
|
|
24
30
|
body["organization_id"] = organization_id
|
|
25
31
|
if expires_at is not None:
|
|
26
32
|
body["expires_at"] = expires_at
|
|
27
|
-
return await self._post("/api-keys", json=body)
|
|
33
|
+
return CreateApiKeyResponse.model_validate(await self._post("/api-keys", json=body))
|
|
28
34
|
|
|
29
|
-
async def list(self, *, include_revoked: bool = False) ->
|
|
35
|
+
async def list(self, *, include_revoked: bool = False) -> ApiKeyListResponse:
|
|
30
36
|
"""Return all API keys, optionally including revoked ones."""
|
|
31
|
-
return
|
|
37
|
+
return ApiKeyListResponse.model_validate(
|
|
38
|
+
await self._get("/api-keys", params={"include_revoked": include_revoked})
|
|
39
|
+
)
|
|
32
40
|
|
|
33
|
-
async def get(self, key_id: str) ->
|
|
41
|
+
async def get(self, key_id: str) -> ApiKeyResponse:
|
|
34
42
|
"""Return a single API key by its ID."""
|
|
35
|
-
return await self._get(f"/api-keys/{key_id}")
|
|
43
|
+
return ApiKeyResponse.model_validate(await self._get(f"/api-keys/{key_id}"))
|
|
36
44
|
|
|
37
|
-
async def revoke(self, key_id: str) ->
|
|
45
|
+
async def revoke(self, key_id: str) -> RevokeApiKeyResponse:
|
|
38
46
|
"""Revoke an API key by its ID."""
|
|
39
|
-
return await self._delete(f"/api-keys/{key_id}")
|
|
47
|
+
return RevokeApiKeyResponse.model_validate(await self._delete(f"/api-keys/{key_id}"))
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Assistant resource for the ModuleX Python SDK.
|
|
2
|
+
|
|
3
|
+
The assistant is the agentic "standard chat" surface. It shares the HITL
|
|
4
|
+
(human-in-the-loop) contract with the composer: a paused run emits a
|
|
5
|
+
``user_input_request`` SSE event answered via :meth:`resume`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from modulex._base import _BaseResource
|
|
13
|
+
from modulex._streaming import EventSourceStream
|
|
14
|
+
from modulex.types.assistant import (
|
|
15
|
+
AssistantCancelResponse,
|
|
16
|
+
AssistantChatDetails,
|
|
17
|
+
AssistantChatListResponse,
|
|
18
|
+
AssistantChatResponse,
|
|
19
|
+
AssistantDeleteResponse,
|
|
20
|
+
AssistantResumeResponse,
|
|
21
|
+
AssistantStatusResponse,
|
|
22
|
+
)
|
|
23
|
+
from modulex.types.realtime import ComposerLLMConfig, UserInputResponse, _to_payload
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Assistant(_BaseResource):
|
|
27
|
+
"""Resource for the agentic assistant chat (all endpoints: org member access)."""
|
|
28
|
+
|
|
29
|
+
async def chat(
|
|
30
|
+
self,
|
|
31
|
+
message: str,
|
|
32
|
+
*,
|
|
33
|
+
chat_id: str | None = None,
|
|
34
|
+
llm: ComposerLLMConfig | dict[str, Any] | None = None,
|
|
35
|
+
organization_id: str | None = None,
|
|
36
|
+
) -> AssistantChatResponse:
|
|
37
|
+
"""Start a new assistant chat or continue an existing one.
|
|
38
|
+
|
|
39
|
+
``llm`` is a provider config ({integration_name, provider_id, model_id,
|
|
40
|
+
credential_id?}); omit it to use the organization's default. Returns a
|
|
41
|
+
``run_id`` — open the event stream with :meth:`listen`.
|
|
42
|
+
"""
|
|
43
|
+
body: dict[str, Any] = {"message": message}
|
|
44
|
+
if chat_id is not None:
|
|
45
|
+
body["chat_id"] = chat_id
|
|
46
|
+
if llm is not None:
|
|
47
|
+
body["llm"] = _to_payload(llm)
|
|
48
|
+
return AssistantChatResponse.model_validate(
|
|
49
|
+
await self._post("/assistant/chat", json=body, organization_id=organization_id)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
async def get(self, chat_id: str, *, organization_id: str | None = None) -> AssistantChatDetails:
|
|
53
|
+
"""Return an assistant chat with all its messages (and any pending HITL question)."""
|
|
54
|
+
return AssistantChatDetails.model_validate(
|
|
55
|
+
await self._get(f"/assistant/chat/{chat_id}", organization_id=organization_id)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
async def list(
|
|
59
|
+
self,
|
|
60
|
+
*,
|
|
61
|
+
limit: int = 20,
|
|
62
|
+
cursor: str | None = None,
|
|
63
|
+
organization_id: str | None = None,
|
|
64
|
+
) -> AssistantChatListResponse:
|
|
65
|
+
"""List the caller's assistant chats (newest first, cursor-paginated)."""
|
|
66
|
+
params: dict[str, Any] = {"limit": limit, "cursor": cursor}
|
|
67
|
+
return AssistantChatListResponse.model_validate(
|
|
68
|
+
await self._get("/assistant/chats", params=params, organization_id=organization_id)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def listen(
|
|
72
|
+
self,
|
|
73
|
+
chat_id: str,
|
|
74
|
+
run_id: str,
|
|
75
|
+
*,
|
|
76
|
+
organization_id: str | None = None,
|
|
77
|
+
) -> EventSourceStream:
|
|
78
|
+
"""Open an SSE stream of live events for an assistant run.
|
|
79
|
+
|
|
80
|
+
Event types are carried in ``event.event`` (normalized from ``data['type']``).
|
|
81
|
+
A ``user_input_request`` event means the run paused for HITL input — answer
|
|
82
|
+
it with :meth:`resume`.
|
|
83
|
+
|
|
84
|
+
A 404 (``NotFoundError``) on connect means the chat/run was not found or is
|
|
85
|
+
not owned by your org — iterating the stream raises it rather than yielding
|
|
86
|
+
events (no reconnect loop, no existence leak).
|
|
87
|
+
"""
|
|
88
|
+
return self._stream_sse(
|
|
89
|
+
f"/assistant/chat/{chat_id}/listen/{run_id}",
|
|
90
|
+
organization_id=organization_id,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
async def resume(
|
|
94
|
+
self,
|
|
95
|
+
chat_id: str,
|
|
96
|
+
*,
|
|
97
|
+
request_id: str,
|
|
98
|
+
response: UserInputResponse | dict[str, Any],
|
|
99
|
+
llm: ComposerLLMConfig | dict[str, Any],
|
|
100
|
+
organization_id: str | None = None,
|
|
101
|
+
) -> AssistantResumeResponse:
|
|
102
|
+
"""Answer a paused HITL ``user_input_request`` and resume the run.
|
|
103
|
+
|
|
104
|
+
``llm`` is required (the executor rebuilds the chat model on resume).
|
|
105
|
+
Returns a NEW ``run_id``; re-subscribe with :meth:`listen` on that run.
|
|
106
|
+
|
|
107
|
+
A 404 (``NotFoundError``) means the chat was not found or is not owned by
|
|
108
|
+
your org (identical 404 in both cases — no existence leak).
|
|
109
|
+
"""
|
|
110
|
+
body: dict[str, Any] = {
|
|
111
|
+
"request_id": request_id,
|
|
112
|
+
"response": _to_payload(response),
|
|
113
|
+
"llm": _to_payload(llm),
|
|
114
|
+
}
|
|
115
|
+
return AssistantResumeResponse.model_validate(
|
|
116
|
+
await self._post(
|
|
117
|
+
f"/assistant/chat/{chat_id}/resume",
|
|
118
|
+
json=body,
|
|
119
|
+
organization_id=organization_id,
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
async def status(self, chat_id: str, *, organization_id: str | None = None) -> AssistantStatusResponse:
|
|
124
|
+
"""Return an assistant chat's status (running / awaiting_input / idle)."""
|
|
125
|
+
return AssistantStatusResponse.model_validate(
|
|
126
|
+
await self._get(f"/assistant/chat/{chat_id}/status", organization_id=organization_id)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
async def cancel(self, chat_id: str, *, organization_id: str | None = None) -> AssistantCancelResponse:
|
|
130
|
+
"""Cancel a running assistant run (also clears any pending HITL question).
|
|
131
|
+
|
|
132
|
+
A 404 (``NotFoundError``) means the chat was not found or is not owned by
|
|
133
|
+
your org (identical 404 in both cases — no existence leak).
|
|
134
|
+
"""
|
|
135
|
+
return AssistantCancelResponse.model_validate(
|
|
136
|
+
await self._post(f"/assistant/chat/{chat_id}/cancel", organization_id=organization_id)
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
async def delete(
|
|
140
|
+
self,
|
|
141
|
+
chat_id: str,
|
|
142
|
+
*,
|
|
143
|
+
permanent: bool = False,
|
|
144
|
+
organization_id: str | None = None,
|
|
145
|
+
) -> AssistantDeleteResponse:
|
|
146
|
+
"""Delete an assistant chat (soft by default; ``permanent=True`` for hard delete)."""
|
|
147
|
+
params: dict[str, Any] = {"permanent": permanent}
|
|
148
|
+
return AssistantDeleteResponse.model_validate(
|
|
149
|
+
await self._delete(
|
|
150
|
+
f"/assistant/chat/{chat_id}",
|
|
151
|
+
params=params,
|
|
152
|
+
organization_id=organization_id,
|
|
153
|
+
)
|
|
154
|
+
)
|
modulex/resources/auth.py
CHANGED
|
@@ -5,34 +5,46 @@ from __future__ import annotations
|
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
7
|
from modulex._base import _BaseResource
|
|
8
|
+
from modulex.types.auth import (
|
|
9
|
+
AcceptInvitationResponse,
|
|
10
|
+
InvitationsResponse,
|
|
11
|
+
LeaveOrganizationResponse,
|
|
12
|
+
RejectInvitationResponse,
|
|
13
|
+
UserOrganizationsResponse,
|
|
14
|
+
UserProfile,
|
|
15
|
+
)
|
|
8
16
|
|
|
9
17
|
|
|
10
18
|
class Auth(_BaseResource):
|
|
11
19
|
"""Resource for authentication and identity endpoints."""
|
|
12
20
|
|
|
13
|
-
async def me(self) ->
|
|
21
|
+
async def me(self) -> UserProfile:
|
|
14
22
|
"""Return the currently authenticated user's profile."""
|
|
15
|
-
return await self._get("/auth/me")
|
|
23
|
+
return UserProfile.model_validate(await self._get("/auth/me"))
|
|
16
24
|
|
|
17
|
-
async def organizations(self, *, role: str | None = None) ->
|
|
25
|
+
async def organizations(self, *, role: str | None = None) -> UserOrganizationsResponse:
|
|
18
26
|
"""Return organizations the current user belongs to, optionally filtered by role."""
|
|
19
27
|
params: dict[str, Any] = {}
|
|
20
28
|
if role is not None:
|
|
21
29
|
params["role"] = role
|
|
22
|
-
return
|
|
30
|
+
return UserOrganizationsResponse.model_validate(
|
|
31
|
+
await self._get("/auth/me/organizations", params=params or None)
|
|
32
|
+
)
|
|
23
33
|
|
|
24
|
-
async def invitations(self) ->
|
|
34
|
+
async def invitations(self) -> InvitationsResponse:
|
|
25
35
|
"""Return all pending invitations for the current user."""
|
|
26
|
-
return await self._get("/auth/invitations/my")
|
|
36
|
+
return InvitationsResponse.model_validate(await self._get("/auth/invitations/my"))
|
|
27
37
|
|
|
28
|
-
async def accept_invitation(self, invitation_id: str) ->
|
|
38
|
+
async def accept_invitation(self, invitation_id: str) -> AcceptInvitationResponse:
|
|
29
39
|
"""Accept a pending organization invitation by its ID."""
|
|
30
|
-
return await self._post(f"/auth/invitations/{invitation_id}/accept")
|
|
40
|
+
return AcceptInvitationResponse.model_validate(await self._post(f"/auth/invitations/{invitation_id}/accept"))
|
|
31
41
|
|
|
32
|
-
async def reject_invitation(self, invitation_id: str) ->
|
|
42
|
+
async def reject_invitation(self, invitation_id: str) -> RejectInvitationResponse:
|
|
33
43
|
"""Reject a pending organization invitation by its ID."""
|
|
34
|
-
return await self._post(f"/auth/invitations/{invitation_id}/reject")
|
|
44
|
+
return RejectInvitationResponse.model_validate(await self._post(f"/auth/invitations/{invitation_id}/reject"))
|
|
35
45
|
|
|
36
|
-
async def leave_organization(self, *, organization_id: str | None = None) ->
|
|
46
|
+
async def leave_organization(self, *, organization_id: str | None = None) -> LeaveOrganizationResponse:
|
|
37
47
|
"""Leave the organization identified by the organization_id header."""
|
|
38
|
-
return
|
|
48
|
+
return LeaveOrganizationResponse.model_validate(
|
|
49
|
+
await self._post("/auth/organizations/leave", organization_id=organization_id)
|
|
50
|
+
)
|
modulex/resources/chats.py
CHANGED
|
@@ -6,18 +6,29 @@ from typing import Any
|
|
|
6
6
|
|
|
7
7
|
from modulex._base import _BaseResource
|
|
8
8
|
from modulex._streaming import EventSourceStream
|
|
9
|
+
from modulex.types.chats import (
|
|
10
|
+
ChatDeleteResponse,
|
|
11
|
+
ChatMessagesListResponse,
|
|
12
|
+
ChatResponse,
|
|
13
|
+
)
|
|
9
14
|
|
|
10
15
|
|
|
11
16
|
class Chats(_BaseResource):
|
|
12
17
|
"""Resource for managing chat sessions and their messages."""
|
|
13
18
|
|
|
14
|
-
async def list(self, *, organization_id: str | None = None) -> Any:
|
|
15
|
-
"""Return all chat sessions for the current user or organization.
|
|
16
|
-
return await self._get("/chats", organization_id=organization_id)
|
|
19
|
+
async def list(self, *, organization_id: str | None = None) -> dict[str, Any]:
|
|
20
|
+
"""Return all chat sessions for the current user or organization.
|
|
17
21
|
|
|
18
|
-
|
|
22
|
+
The backend groups chats under dynamic folder keys (e.g. ``chats``,
|
|
23
|
+
``pinned``, ``archived`` plus any user-defined folders), so this stays a
|
|
24
|
+
raw ``dict[str, list[...]]`` rather than a fixed model.
|
|
25
|
+
"""
|
|
26
|
+
result: dict[str, Any] = await self._get("/chats", organization_id=organization_id)
|
|
27
|
+
return result
|
|
28
|
+
|
|
29
|
+
async def get(self, chat_id: str, *, organization_id: str | None = None) -> ChatResponse:
|
|
19
30
|
"""Return a single chat session by its ID."""
|
|
20
|
-
return await self._get(f"/chats/{chat_id}", organization_id=organization_id)
|
|
31
|
+
return ChatResponse.model_validate(await self._get(f"/chats/{chat_id}", organization_id=organization_id))
|
|
21
32
|
|
|
22
33
|
async def messages(
|
|
23
34
|
self,
|
|
@@ -26,12 +37,14 @@ class Chats(_BaseResource):
|
|
|
26
37
|
limit: int = 20,
|
|
27
38
|
offset: int = 0,
|
|
28
39
|
organization_id: str | None = None,
|
|
29
|
-
) ->
|
|
40
|
+
) -> ChatMessagesListResponse:
|
|
30
41
|
"""Return a paginated list of messages for a chat session."""
|
|
31
|
-
return
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
42
|
+
return ChatMessagesListResponse.model_validate(
|
|
43
|
+
await self._get(
|
|
44
|
+
f"/chats/{chat_id}/messages",
|
|
45
|
+
params={"limit": limit, "offset": offset},
|
|
46
|
+
organization_id=organization_id,
|
|
47
|
+
)
|
|
35
48
|
)
|
|
36
49
|
|
|
37
50
|
async def update(
|
|
@@ -42,7 +55,7 @@ class Chats(_BaseResource):
|
|
|
42
55
|
is_private: bool | None = None,
|
|
43
56
|
folder: str | None = None,
|
|
44
57
|
organization_id: str | None = None,
|
|
45
|
-
) ->
|
|
58
|
+
) -> ChatResponse:
|
|
46
59
|
"""Update metadata for a chat session such as title, privacy, or folder."""
|
|
47
60
|
body: dict[str, Any] = {}
|
|
48
61
|
if title is not None:
|
|
@@ -51,11 +64,15 @@ class Chats(_BaseResource):
|
|
|
51
64
|
body["is_private"] = is_private
|
|
52
65
|
if folder is not None:
|
|
53
66
|
body["folder"] = folder
|
|
54
|
-
return
|
|
67
|
+
return ChatResponse.model_validate(
|
|
68
|
+
await self._patch(f"/chats/{chat_id}", json=body or None, organization_id=organization_id)
|
|
69
|
+
)
|
|
55
70
|
|
|
56
|
-
async def delete(self, chat_id: str, *, organization_id: str | None = None) ->
|
|
71
|
+
async def delete(self, chat_id: str, *, organization_id: str | None = None) -> ChatDeleteResponse:
|
|
57
72
|
"""Delete a chat session by its ID."""
|
|
58
|
-
return
|
|
73
|
+
return ChatDeleteResponse.model_validate(
|
|
74
|
+
await self._delete(f"/chats/{chat_id}", organization_id=organization_id)
|
|
75
|
+
)
|
|
59
76
|
|
|
60
77
|
def stream(self, *, organization_id: str | None = None) -> EventSourceStream:
|
|
61
78
|
"""Open an SSE stream to receive real-time chat events."""
|