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.
Files changed (51) hide show
  1. modulex/__init__.py +12 -2
  2. modulex/_base.py +111 -16
  3. modulex/_client.py +25 -13
  4. modulex/_exceptions.py +172 -8
  5. modulex/_streaming.py +90 -60
  6. modulex/_version.py +5 -0
  7. modulex/resources/api_keys.py +16 -8
  8. modulex/resources/assistant.py +154 -0
  9. modulex/resources/auth.py +24 -12
  10. modulex/resources/chats.py +31 -14
  11. modulex/resources/composer.py +160 -53
  12. modulex/resources/credentials.py +144 -38
  13. modulex/resources/dashboard.py +36 -19
  14. modulex/resources/deployments.py +46 -26
  15. modulex/resources/executions.py +132 -22
  16. modulex/resources/integrations.py +36 -18
  17. modulex/resources/knowledge.py +104 -62
  18. modulex/resources/notifications.py +10 -4
  19. modulex/resources/organizations.py +122 -28
  20. modulex/resources/schedules.py +63 -32
  21. modulex/resources/subscriptions.py +32 -14
  22. modulex/resources/system.py +13 -11
  23. modulex/resources/workflows.py +53 -27
  24. modulex/types/__init__.py +347 -83
  25. modulex/types/_models.py +79 -0
  26. modulex/types/api_keys.py +59 -12
  27. modulex/types/assistant.py +104 -0
  28. modulex/types/auth.py +88 -43
  29. modulex/types/chats.py +65 -37
  30. modulex/types/composer.py +143 -18
  31. modulex/types/credentials.py +95 -54
  32. modulex/types/dashboard.py +247 -46
  33. modulex/types/deployments.py +126 -0
  34. modulex/types/executions.py +132 -101
  35. modulex/types/integrations.py +113 -21
  36. modulex/types/knowledge.py +143 -50
  37. modulex/types/notifications.py +70 -9
  38. modulex/types/organizations.py +131 -27
  39. modulex/types/realtime.py +270 -0
  40. modulex/types/schedules.py +88 -35
  41. modulex/types/subscriptions.py +91 -36
  42. modulex/types/system.py +62 -0
  43. modulex/types/workflows.py +276 -168
  44. {modulex_python-0.1.0.dist-info → modulex_python-1.0.0.dist-info}/METADATA +90 -51
  45. modulex_python-1.0.0.dist-info/RECORD +51 -0
  46. {modulex_python-0.1.0.dist-info → modulex_python-1.0.0.dist-info}/WHEEL +1 -1
  47. modulex/_compat.py +0 -39
  48. modulex/resources/templates.py +0 -115
  49. modulex/types/templates.py +0 -50
  50. modulex_python-0.1.0.dist-info/RECORD +0 -47
  51. {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
- class SSEStream:
27
- """Async iterator for Server-Sent Events streams."""
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
- """Full SSE parser that handles multi-line event blocks."""
76
-
77
- def __init__(self, client: httpx.AsyncClient, method: str, url: str, **kwargs: Any) -> None:
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
- event_type = sse.event or "message"
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=event_type,
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
- """Close the SSE stream."""
147
+ """Stop iterating; the underlying connection closes on context exit."""
118
148
  self._closed = True
modulex/_version.py ADDED
@@ -0,0 +1,5 @@
1
+ """Single source of truth for the SDK version."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "1.0.0"
@@ -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
- ) -> Any:
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) -> Any:
35
+ async def list(self, *, include_revoked: bool = False) -> ApiKeyListResponse:
30
36
  """Return all API keys, optionally including revoked ones."""
31
- return await self._get("/api-keys", params={"include_revoked": include_revoked})
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) -> Any:
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) -> Any:
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) -> Any:
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) -> Any:
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 await self._get("/auth/me/organizations", params=params or None)
30
+ return UserOrganizationsResponse.model_validate(
31
+ await self._get("/auth/me/organizations", params=params or None)
32
+ )
23
33
 
24
- async def invitations(self) -> Any:
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) -> Any:
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) -> Any:
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) -> Any:
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 await self._post("/auth/organizations/leave", organization_id=organization_id)
48
+ return LeaveOrganizationResponse.model_validate(
49
+ await self._post("/auth/organizations/leave", organization_id=organization_id)
50
+ )
@@ -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
- async def get(self, chat_id: str, *, organization_id: str | None = None) -> Any:
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
- ) -> Any:
40
+ ) -> ChatMessagesListResponse:
30
41
  """Return a paginated list of messages for a chat session."""
31
- return await self._get(
32
- f"/chats/{chat_id}/messages",
33
- params={"limit": limit, "offset": offset},
34
- organization_id=organization_id,
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
- ) -> Any:
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 await self._patch(f"/chats/{chat_id}", json=body or None, organization_id=organization_id)
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) -> Any:
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 await self._delete(f"/chats/{chat_id}", organization_id=organization_id)
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."""