assistant-runtime-sdk 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.
- assistant_runtime_sdk/__init__.py +235 -0
- assistant_runtime_sdk/async_client.py +1989 -0
- assistant_runtime_sdk/auth.py +161 -0
- assistant_runtime_sdk/base.py +2277 -0
- assistant_runtime_sdk/client.py +3039 -0
- assistant_runtime_sdk/exceptions.py +146 -0
- assistant_runtime_sdk/skills.py +328 -0
- assistant_runtime_sdk/streaming.py +223 -0
- assistant_runtime_sdk/types.py +546 -0
- assistant_runtime_sdk-1.0.0.dist-info/METADATA +259 -0
- assistant_runtime_sdk-1.0.0.dist-info/RECORD +13 -0
- assistant_runtime_sdk-1.0.0.dist-info/WHEEL +4 -0
- assistant_runtime_sdk-1.0.0.dist-info/licenses/LICENSE +17 -0
|
@@ -0,0 +1,3039 @@
|
|
|
1
|
+
# Assistant Runtime SDK - Synchronous Client
|
|
2
|
+
# Copyright (C) 2025 Paul Clinton
|
|
3
|
+
# AGPL-3.0 License
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
Synchronous Assistant Runtime client using the requests library.
|
|
7
|
+
|
|
8
|
+
For async support, use AsyncAssistantRuntimeClient from assistant_runtime_sdk.async_client.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from typing import Generator, List, Optional, Dict, Any
|
|
14
|
+
|
|
15
|
+
import requests
|
|
16
|
+
|
|
17
|
+
from .base import BaseAssistantRuntimeClient
|
|
18
|
+
from .exceptions import (
|
|
19
|
+
ARAPIError,
|
|
20
|
+
ARAuthenticationError,
|
|
21
|
+
ARTimeoutError,
|
|
22
|
+
ARConnectionError,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AssistantRuntimeClient(BaseAssistantRuntimeClient):
|
|
27
|
+
"""
|
|
28
|
+
Synchronous Assistant Runtime client using the requests library.
|
|
29
|
+
|
|
30
|
+
All methods are blocking and return results directly.
|
|
31
|
+
|
|
32
|
+
Example:
|
|
33
|
+
>>> from assistant_runtime_sdk import AssistantRuntimeClient
|
|
34
|
+
>>> client = AssistantRuntimeClient("tenant-id", "secret", "https://ar.example.com")
|
|
35
|
+
>>> models = client.list_available_models()
|
|
36
|
+
>>> for event in client.stream_chat("session-1", "Hello", user_id="user@example.com"):
|
|
37
|
+
... print(event)
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
# =========================================================================
|
|
41
|
+
# Internal Request Methods
|
|
42
|
+
# =========================================================================
|
|
43
|
+
|
|
44
|
+
def _extract_error_message(self, response) -> Optional[str]:
|
|
45
|
+
"""Extract a human-readable error message from a Frappe HTTP error response."""
|
|
46
|
+
try:
|
|
47
|
+
data = response.json()
|
|
48
|
+
except Exception:
|
|
49
|
+
return None
|
|
50
|
+
return self._extract_error_from_data(data)
|
|
51
|
+
|
|
52
|
+
def _extract_error_payload(self, response) -> tuple:
|
|
53
|
+
"""Return (message, raw_body_dict) so callers can attach both to
|
|
54
|
+
ARAPIError. The raw body is what lets FACO read non-standard keys
|
|
55
|
+
like ``tenant_owner_user_id`` that AR sets on ``frappe.local.response``.
|
|
56
|
+
"""
|
|
57
|
+
try:
|
|
58
|
+
data = response.json()
|
|
59
|
+
except Exception:
|
|
60
|
+
return None, None
|
|
61
|
+
return self._extract_error_from_data(data), data
|
|
62
|
+
|
|
63
|
+
def _request_get(
|
|
64
|
+
self,
|
|
65
|
+
endpoint: str,
|
|
66
|
+
params: Dict[str, Any],
|
|
67
|
+
timeout: Optional[float] = None,
|
|
68
|
+
api_base: Optional[str] = None,
|
|
69
|
+
) -> Optional[Dict[str, Any]]:
|
|
70
|
+
"""Make authenticated GET request with query parameters."""
|
|
71
|
+
url = f"{api_base}.{endpoint}" if api_base else self._build_endpoint_url(endpoint)
|
|
72
|
+
params = self._with_site_url(params)
|
|
73
|
+
headers = self._get_headers(params, for_query_string=True)
|
|
74
|
+
timeout = timeout or self.timeout
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
response = requests.get(url, params=params, headers=headers, timeout=timeout)
|
|
78
|
+
response.raise_for_status()
|
|
79
|
+
return response.json().get("message", response.json())
|
|
80
|
+
except requests.exceptions.Timeout as e:
|
|
81
|
+
self._log_error(f"GET {endpoint} timeout: {e}")
|
|
82
|
+
raise ARTimeoutError(f"Request to {endpoint} timed out") from e
|
|
83
|
+
except requests.exceptions.ConnectionError as e:
|
|
84
|
+
self._log_error(f"GET {endpoint} connection error: {e}")
|
|
85
|
+
raise ARConnectionError(f"Failed to connect to {endpoint}") from e
|
|
86
|
+
except requests.exceptions.HTTPError as e:
|
|
87
|
+
self._log_error(f"GET {endpoint} HTTP error: {e}")
|
|
88
|
+
status_code = e.response.status_code if e.response is not None else None
|
|
89
|
+
msg, body = (
|
|
90
|
+
self._extract_error_payload(e.response) if e.response is not None else (None, None)
|
|
91
|
+
)
|
|
92
|
+
if status_code == 401:
|
|
93
|
+
raise ARAuthenticationError(msg or "Authentication failed") from e
|
|
94
|
+
raise ARAPIError(msg or str(e), status_code=status_code, response_data=body) from e
|
|
95
|
+
except requests.exceptions.RequestException as e:
|
|
96
|
+
self._log_error(f"GET {endpoint} error: {e}")
|
|
97
|
+
raise ARAPIError(str(e)) from e
|
|
98
|
+
|
|
99
|
+
def _request_get_raw(
|
|
100
|
+
self,
|
|
101
|
+
endpoint: str,
|
|
102
|
+
params: Dict[str, Any],
|
|
103
|
+
timeout: Optional[float] = None,
|
|
104
|
+
api_base: Optional[str] = None,
|
|
105
|
+
) -> tuple:
|
|
106
|
+
"""Make authenticated GET request expecting raw binary response.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
(content_bytes, content_type, filename) tuple
|
|
110
|
+
"""
|
|
111
|
+
url = f"{api_base}.{endpoint}" if api_base else self._build_endpoint_url(endpoint)
|
|
112
|
+
params = self._with_site_url(params)
|
|
113
|
+
headers = self._get_headers(params, for_query_string=True)
|
|
114
|
+
timeout = timeout or self.timeout
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
response = requests.get(url, params=params, headers=headers, timeout=timeout)
|
|
118
|
+
response.raise_for_status()
|
|
119
|
+
|
|
120
|
+
content_type = response.headers.get("Content-Type", "application/octet-stream")
|
|
121
|
+
# Extract filename from Content-Disposition header if available
|
|
122
|
+
filename = ""
|
|
123
|
+
cd = response.headers.get("Content-Disposition", "")
|
|
124
|
+
if "filename=" in cd:
|
|
125
|
+
parts = cd.split("filename=")
|
|
126
|
+
if len(parts) > 1:
|
|
127
|
+
filename = parts[1].strip().strip('"')
|
|
128
|
+
|
|
129
|
+
return response.content, content_type, filename
|
|
130
|
+
except requests.exceptions.Timeout as e:
|
|
131
|
+
self._log_error(f"GET raw {endpoint} timeout: {e}")
|
|
132
|
+
raise ARTimeoutError(f"Request to {endpoint} timed out") from e
|
|
133
|
+
except requests.exceptions.ConnectionError as e:
|
|
134
|
+
self._log_error(f"GET raw {endpoint} connection error: {e}")
|
|
135
|
+
raise ARConnectionError(f"Failed to connect to {endpoint}") from e
|
|
136
|
+
except requests.exceptions.HTTPError as e:
|
|
137
|
+
self._log_error(f"GET raw {endpoint} HTTP error: {e}")
|
|
138
|
+
status_code = e.response.status_code if e.response is not None else None
|
|
139
|
+
msg, body = (
|
|
140
|
+
self._extract_error_payload(e.response) if e.response is not None else (None, None)
|
|
141
|
+
)
|
|
142
|
+
if status_code == 401:
|
|
143
|
+
raise ARAuthenticationError(msg or "Authentication failed") from e
|
|
144
|
+
raise ARAPIError(msg or str(e), status_code=status_code, response_data=body) from e
|
|
145
|
+
except requests.exceptions.RequestException as e:
|
|
146
|
+
self._log_error(f"GET raw {endpoint} error: {e}")
|
|
147
|
+
raise ARAPIError(str(e)) from e
|
|
148
|
+
|
|
149
|
+
def _request_post_json(
|
|
150
|
+
self,
|
|
151
|
+
endpoint: str,
|
|
152
|
+
payload: Dict[str, Any],
|
|
153
|
+
timeout: Optional[float] = None,
|
|
154
|
+
api_base: Optional[str] = None,
|
|
155
|
+
) -> Optional[Dict[str, Any]]:
|
|
156
|
+
"""Make authenticated POST request with JSON body."""
|
|
157
|
+
url = f"{api_base}.{endpoint}" if api_base else self._build_endpoint_url(endpoint)
|
|
158
|
+
payload = self._with_site_url(payload)
|
|
159
|
+
headers = {
|
|
160
|
+
**self._get_headers(payload, for_query_string=False),
|
|
161
|
+
"Content-Type": "application/json",
|
|
162
|
+
}
|
|
163
|
+
timeout = timeout or self.timeout
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
response = requests.post(url, json=payload, headers=headers, timeout=timeout)
|
|
167
|
+
response.raise_for_status()
|
|
168
|
+
return response.json().get("message", response.json())
|
|
169
|
+
except requests.exceptions.Timeout as e:
|
|
170
|
+
self._log_error(f"POST {endpoint} timeout: {e}")
|
|
171
|
+
raise ARTimeoutError(f"Request to {endpoint} timed out") from e
|
|
172
|
+
except requests.exceptions.ConnectionError as e:
|
|
173
|
+
self._log_error(f"POST {endpoint} connection error: {e}")
|
|
174
|
+
raise ARConnectionError(f"Failed to connect to {endpoint}") from e
|
|
175
|
+
except requests.exceptions.HTTPError as e:
|
|
176
|
+
self._log_error(f"POST {endpoint} HTTP error: {e}")
|
|
177
|
+
status_code = e.response.status_code if e.response is not None else None
|
|
178
|
+
msg, body = (
|
|
179
|
+
self._extract_error_payload(e.response) if e.response is not None else (None, None)
|
|
180
|
+
)
|
|
181
|
+
if status_code == 401:
|
|
182
|
+
raise ARAuthenticationError(msg or "Authentication failed") from e
|
|
183
|
+
raise ARAPIError(msg or str(e), status_code=status_code, response_data=body) from e
|
|
184
|
+
except requests.exceptions.RequestException as e:
|
|
185
|
+
self._log_error(f"POST {endpoint} error: {e}")
|
|
186
|
+
raise ARAPIError(str(e)) from e
|
|
187
|
+
|
|
188
|
+
def _request_post_form(
|
|
189
|
+
self,
|
|
190
|
+
endpoint: str,
|
|
191
|
+
params: Dict[str, Any],
|
|
192
|
+
timeout: Optional[float] = None,
|
|
193
|
+
api_base: Optional[str] = None,
|
|
194
|
+
) -> Optional[Dict[str, Any]]:
|
|
195
|
+
"""Make authenticated POST request with form-urlencoded body."""
|
|
196
|
+
url = f"{api_base}.{endpoint}" if api_base else self._build_endpoint_url(endpoint)
|
|
197
|
+
params = self._with_site_url(params)
|
|
198
|
+
headers = {
|
|
199
|
+
**self._get_headers(params, for_query_string=True),
|
|
200
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
201
|
+
}
|
|
202
|
+
timeout = timeout or self.timeout
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
response = requests.post(url, data=params, headers=headers, timeout=timeout)
|
|
206
|
+
response.raise_for_status()
|
|
207
|
+
return response.json().get("message", response.json())
|
|
208
|
+
except requests.exceptions.Timeout as e:
|
|
209
|
+
self._log_error(f"POST form {endpoint} timeout: {e}")
|
|
210
|
+
raise ARTimeoutError(f"Request to {endpoint} timed out") from e
|
|
211
|
+
except requests.exceptions.ConnectionError as e:
|
|
212
|
+
self._log_error(f"POST form {endpoint} connection error: {e}")
|
|
213
|
+
raise ARConnectionError(f"Failed to connect to {endpoint}") from e
|
|
214
|
+
except requests.exceptions.HTTPError as e:
|
|
215
|
+
self._log_error(f"POST form {endpoint} HTTP error: {e}")
|
|
216
|
+
status_code = e.response.status_code if e.response is not None else None
|
|
217
|
+
msg, body = (
|
|
218
|
+
self._extract_error_payload(e.response) if e.response is not None else (None, None)
|
|
219
|
+
)
|
|
220
|
+
if status_code == 401:
|
|
221
|
+
raise ARAuthenticationError(msg or "Authentication failed") from e
|
|
222
|
+
raise ARAPIError(msg or str(e), status_code=status_code, response_data=body) from e
|
|
223
|
+
except requests.exceptions.RequestException as e:
|
|
224
|
+
self._log_error(f"POST form {endpoint} error: {e}")
|
|
225
|
+
raise ARAPIError(str(e)) from e
|
|
226
|
+
|
|
227
|
+
def _request_post_multipart(
|
|
228
|
+
self,
|
|
229
|
+
endpoint: str,
|
|
230
|
+
params: Dict[str, Any],
|
|
231
|
+
file_field: str,
|
|
232
|
+
file_name: str,
|
|
233
|
+
file_data: bytes,
|
|
234
|
+
content_type: str = "application/octet-stream",
|
|
235
|
+
timeout: Optional[float] = None,
|
|
236
|
+
api_base: Optional[str] = None,
|
|
237
|
+
) -> Optional[Dict[str, Any]]:
|
|
238
|
+
"""Make authenticated POST request with multipart form data including a file."""
|
|
239
|
+
url = f"{api_base}.{endpoint}" if api_base else self._build_endpoint_url(endpoint)
|
|
240
|
+
# Sign only non-file form fields — Frappe's form_dict excludes file parts
|
|
241
|
+
params = self._with_site_url(params)
|
|
242
|
+
headers = self._get_headers(params, for_query_string=True)
|
|
243
|
+
# Do NOT set Content-Type — requests sets multipart boundary automatically
|
|
244
|
+
timeout = timeout or self.timeout
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
response = requests.post(
|
|
248
|
+
url,
|
|
249
|
+
data=params,
|
|
250
|
+
files={file_field: (file_name, file_data, content_type)},
|
|
251
|
+
headers=headers,
|
|
252
|
+
timeout=timeout,
|
|
253
|
+
)
|
|
254
|
+
response.raise_for_status()
|
|
255
|
+
return response.json().get("message", response.json())
|
|
256
|
+
except requests.exceptions.Timeout as e:
|
|
257
|
+
self._log_error(f"POST multipart {endpoint} timeout: {e}")
|
|
258
|
+
raise ARTimeoutError(f"Request to {endpoint} timed out") from e
|
|
259
|
+
except requests.exceptions.ConnectionError as e:
|
|
260
|
+
self._log_error(f"POST multipart {endpoint} connection error: {e}")
|
|
261
|
+
raise ARConnectionError(f"Failed to connect to {endpoint}") from e
|
|
262
|
+
except requests.exceptions.HTTPError as e:
|
|
263
|
+
self._log_error(f"POST multipart {endpoint} HTTP error: {e}")
|
|
264
|
+
status_code = e.response.status_code if e.response is not None else None
|
|
265
|
+
msg, body = (
|
|
266
|
+
self._extract_error_payload(e.response) if e.response is not None else (None, None)
|
|
267
|
+
)
|
|
268
|
+
if status_code == 401:
|
|
269
|
+
raise ARAuthenticationError(msg or "Authentication failed") from e
|
|
270
|
+
raise ARAPIError(msg or str(e), status_code=status_code, response_data=body) from e
|
|
271
|
+
except requests.exceptions.RequestException as e:
|
|
272
|
+
self._log_error(f"POST multipart {endpoint} error: {e}")
|
|
273
|
+
raise ARAPIError(str(e)) from e
|
|
274
|
+
|
|
275
|
+
def _request_delete(
|
|
276
|
+
self,
|
|
277
|
+
endpoint: str,
|
|
278
|
+
params: Dict[str, Any],
|
|
279
|
+
timeout: Optional[float] = None,
|
|
280
|
+
api_base: Optional[str] = None,
|
|
281
|
+
) -> Optional[Dict[str, Any]]:
|
|
282
|
+
"""Make authenticated DELETE request with query parameters."""
|
|
283
|
+
url = f"{api_base}.{endpoint}" if api_base else self._build_endpoint_url(endpoint)
|
|
284
|
+
params = self._with_site_url(params)
|
|
285
|
+
headers = self._get_headers(params, for_query_string=True)
|
|
286
|
+
timeout = timeout or self.timeout
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
response = requests.delete(url, params=params, headers=headers, timeout=timeout)
|
|
290
|
+
response.raise_for_status()
|
|
291
|
+
return response.json().get("message", response.json())
|
|
292
|
+
except requests.exceptions.Timeout as e:
|
|
293
|
+
self._log_error(f"DELETE {endpoint} timeout: {e}")
|
|
294
|
+
raise ARTimeoutError(f"Request to {endpoint} timed out") from e
|
|
295
|
+
except requests.exceptions.ConnectionError as e:
|
|
296
|
+
self._log_error(f"DELETE {endpoint} connection error: {e}")
|
|
297
|
+
raise ARConnectionError(f"Failed to connect to {endpoint}") from e
|
|
298
|
+
except requests.exceptions.HTTPError as e:
|
|
299
|
+
self._log_error(f"DELETE {endpoint} HTTP error: {e}")
|
|
300
|
+
status_code = e.response.status_code if e.response is not None else None
|
|
301
|
+
msg, body = (
|
|
302
|
+
self._extract_error_payload(e.response) if e.response is not None else (None, None)
|
|
303
|
+
)
|
|
304
|
+
if status_code == 401:
|
|
305
|
+
raise ARAuthenticationError(msg or "Authentication failed") from e
|
|
306
|
+
raise ARAPIError(msg or str(e), status_code=status_code, response_data=body) from e
|
|
307
|
+
except requests.exceptions.RequestException as e:
|
|
308
|
+
self._log_error(f"DELETE {endpoint} error: {e}")
|
|
309
|
+
raise ARAPIError(str(e)) from e
|
|
310
|
+
|
|
311
|
+
# =========================================================================
|
|
312
|
+
# Streaming API
|
|
313
|
+
# =========================================================================
|
|
314
|
+
|
|
315
|
+
def stream_chat(
|
|
316
|
+
self,
|
|
317
|
+
session_id: str,
|
|
318
|
+
message: Optional[str],
|
|
319
|
+
user_id: str,
|
|
320
|
+
context: Optional[Dict[str, Any]] = None,
|
|
321
|
+
model_id: Optional[str] = None,
|
|
322
|
+
attachments: Optional[List[Dict[str, Any]]] = None,
|
|
323
|
+
system_prompt_addendum: Optional[str] = None,
|
|
324
|
+
client_type: Optional[str] = None,
|
|
325
|
+
interrupt_response: Optional[List[Dict[str, str]]] = None,
|
|
326
|
+
message_id: Optional[str] = None,
|
|
327
|
+
session_state: Optional[Dict[str, Any]] = None,
|
|
328
|
+
continue_from_message_id: Optional[str] = None,
|
|
329
|
+
*,
|
|
330
|
+
web_search: Optional[bool] = None,
|
|
331
|
+
thinking_enabled: Optional[bool] = None,
|
|
332
|
+
) -> Generator[Dict[str, Any], None, None]:
|
|
333
|
+
"""
|
|
334
|
+
Stream chat response from Assistant Runtime.
|
|
335
|
+
|
|
336
|
+
Connects to Assistant Runtime's SSE endpoint and yields parsed events.
|
|
337
|
+
Also handles HITL resume when interrupt_response is provided.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
session_id: Conversation session identifier
|
|
341
|
+
message: User's message to send (optional when interrupt_response is provided)
|
|
342
|
+
user_id: User identifier (required)
|
|
343
|
+
context: Optional page context (doctype, document info, etc.)
|
|
344
|
+
model_id: Optional model ID to use (use "auto" for auto-selection)
|
|
345
|
+
attachments: Optional list of attachments (images/documents)
|
|
346
|
+
Each attachment: {
|
|
347
|
+
"type": "image" | "document",
|
|
348
|
+
"format": "png" | "jpeg" | "gif" | "webp" | "pdf" | "txt",
|
|
349
|
+
"data": "<base64-encoded-data>",
|
|
350
|
+
"name": "optional-filename.png", # Optional
|
|
351
|
+
"file_url": "/files/..." # Optional, for storage reference
|
|
352
|
+
}
|
|
353
|
+
system_prompt_addendum: Optional per-request addition to the system prompt
|
|
354
|
+
interrupt_response: Optional HITL resume responses. Each item:
|
|
355
|
+
{"interruptId": str, "response": "approve"|"rejected"|"trust"|"session"}
|
|
356
|
+
message_id: Optional message ID to reuse on HITL resume
|
|
357
|
+
session_state: Optional client-held signed session blob (zero-retention).
|
|
358
|
+
On ``stream_complete``, ``event['session_state']`` is the updated
|
|
359
|
+
signed blob — store it and pass it as ``session_state`` on the next turn.
|
|
360
|
+
continue_from_message_id: Optional message ID to continue a previous
|
|
361
|
+
truncated response from
|
|
362
|
+
web_search: Optional explicit web-search toggle for this turn
|
|
363
|
+
thinking_enabled: Optional explicit extended-thinking toggle for this turn
|
|
364
|
+
|
|
365
|
+
Yields:
|
|
366
|
+
Parsed SSE events with structure:
|
|
367
|
+
{
|
|
368
|
+
"event": "stream_start|stream_chunk|stream_complete|stream_error|...",
|
|
369
|
+
"data": {...event-specific data...}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
Example:
|
|
373
|
+
>>> for event in client.stream_chat("session-1", "Hello", "user@example.com"):
|
|
374
|
+
... if event["event"] == "stream_chunk":
|
|
375
|
+
... print(event["data"].get("content", ""), end="")
|
|
376
|
+
|
|
377
|
+
# Resume from HITL interrupt
|
|
378
|
+
>>> for event in client.stream_chat(
|
|
379
|
+
... "session-1", None, "user@example.com",
|
|
380
|
+
... interrupt_response=[{"interruptId": "abc", "response": "approve"}]
|
|
381
|
+
... ):
|
|
382
|
+
... print(event)
|
|
383
|
+
"""
|
|
384
|
+
payload = self._prepare_stream_payload(
|
|
385
|
+
session_id, message, user_id, context, model_id, attachments,
|
|
386
|
+
system_prompt_addendum, client_type=client_type,
|
|
387
|
+
interrupt_response=interrupt_response,
|
|
388
|
+
message_id=message_id,
|
|
389
|
+
session_state=session_state,
|
|
390
|
+
continue_from_message_id=continue_from_message_id,
|
|
391
|
+
web_search=web_search,
|
|
392
|
+
thinking_enabled=thinking_enabled,
|
|
393
|
+
)
|
|
394
|
+
url = self._build_endpoint_url("streaming.stream_chat")
|
|
395
|
+
payload = self._with_site_url(payload)
|
|
396
|
+
headers = self._get_stream_headers(payload, for_json_body=True)
|
|
397
|
+
|
|
398
|
+
try:
|
|
399
|
+
with requests.post(
|
|
400
|
+
url,
|
|
401
|
+
json=payload,
|
|
402
|
+
headers=headers,
|
|
403
|
+
stream=True,
|
|
404
|
+
timeout=(self.STREAM_CONNECT_TIMEOUT, self.STREAM_READ_TIMEOUT),
|
|
405
|
+
) as response:
|
|
406
|
+
response.raise_for_status()
|
|
407
|
+
|
|
408
|
+
current_event = None
|
|
409
|
+
|
|
410
|
+
for line in response.iter_lines(decode_unicode=True):
|
|
411
|
+
if line is None:
|
|
412
|
+
continue
|
|
413
|
+
|
|
414
|
+
parsed = self._parse_sse_line(line)
|
|
415
|
+
if not parsed:
|
|
416
|
+
continue
|
|
417
|
+
|
|
418
|
+
if parsed["type"] == "heartbeat":
|
|
419
|
+
yield {"event": "heartbeat", "data": {}}
|
|
420
|
+
continue
|
|
421
|
+
if parsed["type"] == "event_name":
|
|
422
|
+
current_event = parsed["value"]
|
|
423
|
+
elif parsed["type"] == "data":
|
|
424
|
+
yield {"event": current_event or "unknown", "data": parsed["value"]}
|
|
425
|
+
current_event = None
|
|
426
|
+
|
|
427
|
+
except requests.exceptions.Timeout as e:
|
|
428
|
+
yield {
|
|
429
|
+
"event": "stream_error",
|
|
430
|
+
"data": {
|
|
431
|
+
"error": "The assistant took too long to respond. Please try again.",
|
|
432
|
+
"error_code": "TIMEOUT",
|
|
433
|
+
"_detail": str(e),
|
|
434
|
+
},
|
|
435
|
+
}
|
|
436
|
+
except requests.exceptions.ChunkedEncodingError as e:
|
|
437
|
+
# Connection dropped mid-stream — typically a proxy idle timeout,
|
|
438
|
+
# worker restart, or OOM kill on the server side. ChunkedEncodingError
|
|
439
|
+
# is a subclass of ConnectionError, so it must be caught first.
|
|
440
|
+
yield {
|
|
441
|
+
"event": "stream_error",
|
|
442
|
+
"data": {
|
|
443
|
+
"error": "Connection to the assistant was interrupted. Please try again.",
|
|
444
|
+
"error_code": "CONNECTION_INTERRUPTED",
|
|
445
|
+
"_detail": str(e),
|
|
446
|
+
},
|
|
447
|
+
}
|
|
448
|
+
except requests.exceptions.ConnectionError as e:
|
|
449
|
+
yield {
|
|
450
|
+
"event": "stream_error",
|
|
451
|
+
"data": {
|
|
452
|
+
"error": "Couldn't reach the assistant service. Please check your connection and try again.",
|
|
453
|
+
"error_code": "CONNECTION_ERROR",
|
|
454
|
+
"_detail": str(e),
|
|
455
|
+
},
|
|
456
|
+
}
|
|
457
|
+
except requests.exceptions.RequestException as e:
|
|
458
|
+
yield {
|
|
459
|
+
"event": "stream_error",
|
|
460
|
+
"data": {
|
|
461
|
+
"error": "Something went wrong with your request. Please try again.",
|
|
462
|
+
"error_code": "REQUEST_ERROR",
|
|
463
|
+
"_detail": str(e),
|
|
464
|
+
},
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
# =========================================================================
|
|
468
|
+
# Tenant APIs
|
|
469
|
+
# =========================================================================
|
|
470
|
+
|
|
471
|
+
def get_tenant_info(self) -> Optional[Dict[str, Any]]:
|
|
472
|
+
"""Get tenant information including subscription status."""
|
|
473
|
+
endpoint, params = self._prepare_get_tenant_info()
|
|
474
|
+
return self._request_get(endpoint, params)
|
|
475
|
+
|
|
476
|
+
def accept_terms(self, terms_version: str, accepted_by: str) -> Dict[str, Any]:
|
|
477
|
+
"""Accept or re-accept Terms and Conditions for this tenant."""
|
|
478
|
+
endpoint, payload = self._prepare_accept_terms(terms_version, accepted_by)
|
|
479
|
+
return self._request_post_json(endpoint, payload)
|
|
480
|
+
|
|
481
|
+
def get_terms_status(self, timeout: Optional[float] = None) -> Optional[Dict[str, Any]]:
|
|
482
|
+
"""Report whether this tenant must (re-)accept Terms and Conditions.
|
|
483
|
+
|
|
484
|
+
Read-only — safe to call at boot to decide whether to render the
|
|
485
|
+
acceptance surface before a gated feature fails.
|
|
486
|
+
"""
|
|
487
|
+
endpoint, params = self._prepare_get_terms_status()
|
|
488
|
+
return self._request_get(endpoint, params, timeout=timeout)
|
|
489
|
+
|
|
490
|
+
# =========================================================================
|
|
491
|
+
# Model APIs
|
|
492
|
+
# =========================================================================
|
|
493
|
+
|
|
494
|
+
def list_available_models(self, timeout: Optional[float] = None) -> Optional[Dict[str, Any]]:
|
|
495
|
+
"""
|
|
496
|
+
List available AI models for this tenant's subscription tier.
|
|
497
|
+
|
|
498
|
+
Args:
|
|
499
|
+
timeout: Optional request timeout in seconds (overrides client default)
|
|
500
|
+
|
|
501
|
+
Returns:
|
|
502
|
+
Dict with models list, auto_mode info, and default model
|
|
503
|
+
"""
|
|
504
|
+
endpoint, params = self._prepare_list_available_models()
|
|
505
|
+
return self._request_get(endpoint, params, timeout=timeout)
|
|
506
|
+
|
|
507
|
+
def get_available_models(self) -> Optional[Dict[str, Any]]:
|
|
508
|
+
"""Get available AI models (deprecated). Use list_available_models() instead."""
|
|
509
|
+
endpoint, params = self._prepare_get_available_models()
|
|
510
|
+
return self._request_get(endpoint, params)
|
|
511
|
+
|
|
512
|
+
def set_preferred_model(self, model_id: str) -> bool:
|
|
513
|
+
"""Set the preferred AI model for this tenant."""
|
|
514
|
+
endpoint, payload = self._prepare_set_preferred_model(model_id)
|
|
515
|
+
result = self._request_post_json(endpoint, payload)
|
|
516
|
+
return result.get("success", False) if result else False
|
|
517
|
+
|
|
518
|
+
# =========================================================================
|
|
519
|
+
# Origin Binding APIs (Phase 2)
|
|
520
|
+
# =========================================================================
|
|
521
|
+
|
|
522
|
+
def request_rebind(self, new_site_url: str) -> Dict[str, Any]:
|
|
523
|
+
"""Initiate a tenant rebind. Returns {"rebind_token": str, "expires_at": str}.
|
|
524
|
+
|
|
525
|
+
When the site URL of an installation changes, call this to begin the
|
|
526
|
+
rebind flow. AR sends a confirmation email to the tenant owner. After
|
|
527
|
+
the owner clicks the link, call the module-level helper
|
|
528
|
+
``get_rotated_secret(ar_url, rebind_token)`` with the token from the
|
|
529
|
+
link to retrieve the new ``tenant_secret``.
|
|
530
|
+
"""
|
|
531
|
+
return self._request_post_json(
|
|
532
|
+
"request_rebind",
|
|
533
|
+
{"tenant_id": self.tenant_id, "new_site_url": new_site_url},
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
def claim_tenant_email(self, owner_email: str) -> Dict[str, Any]:
|
|
537
|
+
"""Bootstrap an owner_email on a legacy tenant (one without a verified
|
|
538
|
+
owner_email on file). AR sends a verification email; the email is only
|
|
539
|
+
persisted to the tenant record after the owner clicks the link.
|
|
540
|
+
"""
|
|
541
|
+
return self._request_post_json(
|
|
542
|
+
"claim_tenant_email",
|
|
543
|
+
{"tenant_id": self.tenant_id, "owner_email": owner_email},
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
def diagnose_registration(self) -> Dict[str, Any]:
|
|
547
|
+
"""Diagnose registration issues. Returns one of:
|
|
548
|
+
|
|
549
|
+
- ``{"status": "ok", "bound_host": ..., "last_verification": ...}``
|
|
550
|
+
- ``{"status": "origin_mismatch", "bound_host": ..., "claimed_host": ...,
|
|
551
|
+
"remediation": "rebind", "rebind_url": ...}``
|
|
552
|
+
"""
|
|
553
|
+
return self._request_post_json(
|
|
554
|
+
"diagnose_registration",
|
|
555
|
+
{"tenant_id": self.tenant_id},
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
# =========================================================================
|
|
559
|
+
# Prompt APIs
|
|
560
|
+
# =========================================================================
|
|
561
|
+
|
|
562
|
+
def list_prompts(self, user_id: str, cursor: Optional[str] = None, timeout: Optional[float] = None) -> Optional[Dict[str, Any]]:
|
|
563
|
+
"""List available prompt templates from user's configured MCP servers."""
|
|
564
|
+
endpoint, params = self._prepare_list_prompts(user_id, cursor)
|
|
565
|
+
return self._request_get(endpoint, params, timeout=timeout)
|
|
566
|
+
|
|
567
|
+
def get_prompt(
|
|
568
|
+
self,
|
|
569
|
+
prompt_name: str,
|
|
570
|
+
user_id: str,
|
|
571
|
+
arguments: Optional[Dict[str, Any]] = None,
|
|
572
|
+
) -> Optional[Dict[str, Any]]:
|
|
573
|
+
"""Get a specific prompt rendered with provided arguments."""
|
|
574
|
+
endpoint, payload = self._prepare_get_prompt(prompt_name, user_id, arguments)
|
|
575
|
+
return self._request_post_json(endpoint, payload)
|
|
576
|
+
|
|
577
|
+
# =========================================================================
|
|
578
|
+
# Suggestion APIs
|
|
579
|
+
# =========================================================================
|
|
580
|
+
|
|
581
|
+
def get_suggestions(
|
|
582
|
+
self,
|
|
583
|
+
user_id: str,
|
|
584
|
+
context: Optional[Dict[str, Any]] = None,
|
|
585
|
+
limit: int = 8,
|
|
586
|
+
timeout: Optional[float] = None,
|
|
587
|
+
) -> Optional[Dict[str, Any]]:
|
|
588
|
+
"""
|
|
589
|
+
Get personalized prompt suggestions based on user conversation history.
|
|
590
|
+
|
|
591
|
+
Uses frequency analysis of past conversations, frequently-used DocTypes,
|
|
592
|
+
and extracted memories to surface relevant suggestions. No LLM cost.
|
|
593
|
+
|
|
594
|
+
Args:
|
|
595
|
+
user_id: User identifier (required)
|
|
596
|
+
context: Optional page context {"type": "Form", "doctype": "Sales Invoice"}
|
|
597
|
+
limit: Max suggestions to return (default 8, max 20)
|
|
598
|
+
timeout: Optional request timeout in seconds (overrides client default)
|
|
599
|
+
|
|
600
|
+
Returns:
|
|
601
|
+
Dict with suggestions, history flag, and stats:
|
|
602
|
+
{
|
|
603
|
+
"suggestions": [
|
|
604
|
+
{"text": str, "source": "history"|"memory"|"doctype", "score": float}
|
|
605
|
+
],
|
|
606
|
+
"has_history": bool,
|
|
607
|
+
"stats": {"total_conversations": int, "top_doctypes": [str]}
|
|
608
|
+
}
|
|
609
|
+
"""
|
|
610
|
+
endpoint, params = self._prepare_get_suggestions(user_id, context, limit)
|
|
611
|
+
return self._request_get(endpoint, params, timeout=timeout)
|
|
612
|
+
|
|
613
|
+
def generate_curated_suggestions(
|
|
614
|
+
self,
|
|
615
|
+
user_id: str,
|
|
616
|
+
signals: Optional[Dict[str, Any]] = None,
|
|
617
|
+
timeout: Optional[float] = None,
|
|
618
|
+
) -> Optional[Dict[str, Any]]:
|
|
619
|
+
"""Generate LLM-curated welcome suggestions from usage signals.
|
|
620
|
+
|
|
621
|
+
Costs one Economy-tier completion on AR (rate-limited server-side to
|
|
622
|
+
one generation per user per 20h). Returns
|
|
623
|
+
{"suggestions": [...], "generated_at": ...} or an empty-suggestions
|
|
624
|
+
dict with a reason/rate_limited flag.
|
|
625
|
+
"""
|
|
626
|
+
endpoint, payload = self._prepare_generate_curated_suggestions(user_id, signals)
|
|
627
|
+
return self._request_post_json(endpoint, payload, timeout=timeout)
|
|
628
|
+
|
|
629
|
+
# =========================================================================
|
|
630
|
+
# Onboarding APIs
|
|
631
|
+
# =========================================================================
|
|
632
|
+
# These methods route through memory_api_base -> assistant_runtime_memory.api
|
|
633
|
+
|
|
634
|
+
def get_onboarding_status(self, user_id: str) -> Optional[Dict[str, Any]]:
|
|
635
|
+
"""
|
|
636
|
+
Check if a user has completed onboarding.
|
|
637
|
+
|
|
638
|
+
Args:
|
|
639
|
+
user_id: User identifier (required)
|
|
640
|
+
|
|
641
|
+
Returns:
|
|
642
|
+
{"onboarding_complete": bool, "has_conversations": bool}
|
|
643
|
+
"""
|
|
644
|
+
endpoint, params = self._prepare_get_onboarding_status(user_id)
|
|
645
|
+
return self._request_get(endpoint, params, api_base=self.memory_api_base)
|
|
646
|
+
|
|
647
|
+
def complete_onboarding(
|
|
648
|
+
self,
|
|
649
|
+
user_id: str,
|
|
650
|
+
) -> Optional[Dict[str, Any]]:
|
|
651
|
+
"""
|
|
652
|
+
Mark onboarding complete for a user.
|
|
653
|
+
|
|
654
|
+
Memory is seeded from real chat turns via turn-end extraction, so this
|
|
655
|
+
only flips the completion flag and refreshes personalized suggestions.
|
|
656
|
+
|
|
657
|
+
Args:
|
|
658
|
+
user_id: User identifier (required)
|
|
659
|
+
|
|
660
|
+
Returns:
|
|
661
|
+
{"success": bool}
|
|
662
|
+
"""
|
|
663
|
+
endpoint, payload = self._prepare_complete_onboarding(user_id)
|
|
664
|
+
return self._request_post_json(endpoint, payload, api_base=self.memory_api_base)
|
|
665
|
+
|
|
666
|
+
# =========================================================================
|
|
667
|
+
# Document APIs (RAG)
|
|
668
|
+
# =========================================================================
|
|
669
|
+
# These methods route through memory_api_base -> assistant_runtime_memory.api
|
|
670
|
+
|
|
671
|
+
def upload_document(
|
|
672
|
+
self,
|
|
673
|
+
file_path: Optional[str] = None,
|
|
674
|
+
file_data: Optional[bytes] = None,
|
|
675
|
+
file_name: Optional[str] = None,
|
|
676
|
+
content_type: Optional[str] = None,
|
|
677
|
+
user_id: Optional[str] = None,
|
|
678
|
+
visibility: Optional[str] = None,
|
|
679
|
+
shared_with: Optional[List[str]] = None,
|
|
680
|
+
) -> Optional[Dict[str, Any]]:
|
|
681
|
+
"""
|
|
682
|
+
Upload a document for RAG processing.
|
|
683
|
+
|
|
684
|
+
Accepts either a file path or raw bytes. Supported formats: PDF,
|
|
685
|
+
Markdown (.md), and plain text (.txt).
|
|
686
|
+
|
|
687
|
+
Args:
|
|
688
|
+
file_path: Path to the file to upload. Mutually exclusive with file_data.
|
|
689
|
+
file_data: Raw file bytes. Requires file_name.
|
|
690
|
+
file_name: Filename (required with file_data, optional with file_path).
|
|
691
|
+
content_type: MIME type override. Auto-detected from extension if omitted.
|
|
692
|
+
user_id: Uploader's user identifier. Enables ownership tracking.
|
|
693
|
+
visibility: Document visibility (public, private, shared). Defaults to
|
|
694
|
+
tenant configuration if omitted.
|
|
695
|
+
shared_with: List of user IDs to share with. Required when
|
|
696
|
+
visibility is "shared".
|
|
697
|
+
|
|
698
|
+
Returns:
|
|
699
|
+
{"status": "queued", "document_id": str, "file_name": str,
|
|
700
|
+
"file_size_mb": float, "visibility": str, "message": str}
|
|
701
|
+
"""
|
|
702
|
+
endpoint, params, f_field, f_name, f_data, c_type = self._prepare_upload_document(
|
|
703
|
+
file_path, file_data, file_name, content_type,
|
|
704
|
+
user_id=user_id, visibility=visibility, shared_with=shared_with,
|
|
705
|
+
)
|
|
706
|
+
return self._request_post_multipart(
|
|
707
|
+
endpoint, params=params, file_field=f_field,
|
|
708
|
+
file_name=f_name, file_data=f_data, content_type=c_type,
|
|
709
|
+
timeout=120.0, api_base=self.memory_api_base,
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
def list_documents(
|
|
713
|
+
self, limit: int = 50, offset: int = 0, user_id: Optional[str] = None
|
|
714
|
+
) -> Optional[Dict[str, Any]]:
|
|
715
|
+
"""
|
|
716
|
+
List RAG documents for this tenant.
|
|
717
|
+
|
|
718
|
+
When user_id is provided, returns documents visible to that user
|
|
719
|
+
(public + user's private + shared). Without user_id, returns only
|
|
720
|
+
public documents.
|
|
721
|
+
|
|
722
|
+
Args:
|
|
723
|
+
limit: Maximum number of documents to return (default 50).
|
|
724
|
+
offset: Pagination offset (default 0).
|
|
725
|
+
user_id: User performing the query. Enables visibility filtering.
|
|
726
|
+
|
|
727
|
+
Returns:
|
|
728
|
+
{"documents": [...], "pagination": {...}, "storage": {...}}
|
|
729
|
+
"""
|
|
730
|
+
endpoint, params = self._prepare_list_documents(limit, offset, user_id=user_id)
|
|
731
|
+
return self._request_get(endpoint, params, api_base=self.memory_api_base)
|
|
732
|
+
|
|
733
|
+
def get_document(self, document_id: str) -> Optional[Dict[str, Any]]:
|
|
734
|
+
"""
|
|
735
|
+
Get detailed information about a specific RAG document.
|
|
736
|
+
|
|
737
|
+
Args:
|
|
738
|
+
document_id: The document identifier.
|
|
739
|
+
|
|
740
|
+
Returns:
|
|
741
|
+
Document details including embedding_status, total_chunks,
|
|
742
|
+
visibility, uploaded_by, and processing_error (if Failed).
|
|
743
|
+
"""
|
|
744
|
+
endpoint, params = self._prepare_get_document(document_id)
|
|
745
|
+
return self._request_get(endpoint, params, api_base=self.memory_api_base)
|
|
746
|
+
|
|
747
|
+
def list_chunks(
|
|
748
|
+
self,
|
|
749
|
+
document_id: str,
|
|
750
|
+
user_id: Optional[str] = None,
|
|
751
|
+
search: Optional[str] = None,
|
|
752
|
+
limit: int = 50,
|
|
753
|
+
offset: int = 0,
|
|
754
|
+
) -> Optional[Dict[str, Any]]:
|
|
755
|
+
"""
|
|
756
|
+
List stored chunks for a document (read-only chunk browser).
|
|
757
|
+
|
|
758
|
+
Visibility is enforced backend-side using user_id (private/shared
|
|
759
|
+
documents require the requesting user to own or be granted access).
|
|
760
|
+
|
|
761
|
+
Args:
|
|
762
|
+
document_id: Document whose chunks to list.
|
|
763
|
+
user_id: Requesting user. Enables visibility enforcement.
|
|
764
|
+
search: Optional case-insensitive substring filter on chunk text.
|
|
765
|
+
limit: Page size (default 50).
|
|
766
|
+
offset: Pagination offset (default 0).
|
|
767
|
+
|
|
768
|
+
Returns:
|
|
769
|
+
{"chunks": [...], "total": int, "pagination": {...}}
|
|
770
|
+
"""
|
|
771
|
+
endpoint, params = self._prepare_list_chunks(
|
|
772
|
+
document_id, user_id=user_id, search=search, limit=limit, offset=offset
|
|
773
|
+
)
|
|
774
|
+
return self._request_get(endpoint, params, api_base=self.memory_api_base)
|
|
775
|
+
|
|
776
|
+
def get_document_content(
|
|
777
|
+
self, document_id: str, user_id: Optional[str] = None
|
|
778
|
+
) -> tuple:
|
|
779
|
+
"""
|
|
780
|
+
Download raw file content for a RAG document.
|
|
781
|
+
|
|
782
|
+
Enforces visibility rules on the AR backend. Returns the file bytes
|
|
783
|
+
along with content type and filename for serving to the browser.
|
|
784
|
+
|
|
785
|
+
Args:
|
|
786
|
+
document_id: The document identifier.
|
|
787
|
+
user_id: Requesting user for visibility checks.
|
|
788
|
+
|
|
789
|
+
Returns:
|
|
790
|
+
(file_bytes, content_type, filename) tuple
|
|
791
|
+
"""
|
|
792
|
+
endpoint, params = self._prepare_get_document_content(document_id, user_id=user_id)
|
|
793
|
+
return self._request_get_raw(endpoint, params, api_base=self.memory_api_base)
|
|
794
|
+
|
|
795
|
+
def delete_document(
|
|
796
|
+
self, document_id: str, user_id: Optional[str] = None
|
|
797
|
+
) -> Optional[Dict[str, Any]]:
|
|
798
|
+
"""
|
|
799
|
+
Delete a RAG document and remove its embeddings.
|
|
800
|
+
|
|
801
|
+
Performs a soft delete -- the document is marked as deleted and its
|
|
802
|
+
vector embeddings are removed from the search index. For private and
|
|
803
|
+
shared documents, only the owner (uploaded_by) can delete.
|
|
804
|
+
|
|
805
|
+
Args:
|
|
806
|
+
document_id: The document identifier to delete.
|
|
807
|
+
user_id: User requesting deletion. Used for ownership verification.
|
|
808
|
+
|
|
809
|
+
Returns:
|
|
810
|
+
{"status": "deleted", "document_id": str, "file_size_mb": float}
|
|
811
|
+
"""
|
|
812
|
+
endpoint, payload = self._prepare_delete_document(document_id, user_id=user_id)
|
|
813
|
+
return self._request_post_json(endpoint, payload, api_base=self.memory_api_base)
|
|
814
|
+
|
|
815
|
+
def update_document_access(
|
|
816
|
+
self,
|
|
817
|
+
document_id: str,
|
|
818
|
+
user_id: str,
|
|
819
|
+
visibility: Optional[str] = None,
|
|
820
|
+
add_users: Optional[List[str]] = None,
|
|
821
|
+
remove_users: Optional[List[str]] = None,
|
|
822
|
+
) -> Optional[Dict[str, Any]]:
|
|
823
|
+
"""
|
|
824
|
+
Update document visibility and sharing.
|
|
825
|
+
|
|
826
|
+
Only the document owner (uploaded_by) can manage access.
|
|
827
|
+
|
|
828
|
+
Args:
|
|
829
|
+
document_id: The document identifier.
|
|
830
|
+
user_id: User requesting the change. Must match uploaded_by.
|
|
831
|
+
visibility: New visibility (public, private, shared).
|
|
832
|
+
add_users: User IDs to grant access (for shared visibility).
|
|
833
|
+
remove_users: User IDs to revoke access.
|
|
834
|
+
|
|
835
|
+
Returns:
|
|
836
|
+
{"status": "updated", "document_id": str, "visibility": str,
|
|
837
|
+
"shared_with": [str]}
|
|
838
|
+
"""
|
|
839
|
+
endpoint, payload = self._prepare_update_document_access(
|
|
840
|
+
document_id, user_id,
|
|
841
|
+
visibility=visibility, add_users=add_users, remove_users=remove_users,
|
|
842
|
+
)
|
|
843
|
+
return self._request_post_json(endpoint, payload, api_base=self.memory_api_base)
|
|
844
|
+
|
|
845
|
+
def get_storage_info(self) -> Optional[Dict[str, Any]]:
|
|
846
|
+
"""
|
|
847
|
+
Get storage quota and usage information for this tenant's RAG documents.
|
|
848
|
+
|
|
849
|
+
Returns:
|
|
850
|
+
{"quota_mb": float, "used_mb": float, "available_mb": float,
|
|
851
|
+
"usage_percentage": float, "document_count": int}
|
|
852
|
+
"""
|
|
853
|
+
endpoint, params = self._prepare_get_storage_info()
|
|
854
|
+
return self._request_get(endpoint, params, api_base=self.memory_api_base)
|
|
855
|
+
|
|
856
|
+
# =========================================================================
|
|
857
|
+
# Memory APIs (User Memory Viewer)
|
|
858
|
+
# =========================================================================
|
|
859
|
+
# These methods route through memory_api_base -> assistant_runtime_memory.api
|
|
860
|
+
|
|
861
|
+
def list_memories(
|
|
862
|
+
self,
|
|
863
|
+
user_id: Optional[str] = None,
|
|
864
|
+
memory_type: Optional[str] = None,
|
|
865
|
+
limit: int = 50,
|
|
866
|
+
offset: int = 0,
|
|
867
|
+
) -> Optional[Dict[str, Any]]:
|
|
868
|
+
"""
|
|
869
|
+
List stored memories for this tenant.
|
|
870
|
+
|
|
871
|
+
Args:
|
|
872
|
+
user_id: Optional user filter.
|
|
873
|
+
memory_type: Optional type filter (preference, summary, fact).
|
|
874
|
+
limit: Page size (default 50).
|
|
875
|
+
offset: Pagination offset (default 0).
|
|
876
|
+
|
|
877
|
+
Returns:
|
|
878
|
+
{"memories": [...], "pagination": {...}}
|
|
879
|
+
"""
|
|
880
|
+
endpoint, params = self._prepare_list_memories(user_id, memory_type, limit, offset)
|
|
881
|
+
return self._request_get(endpoint, params, api_base=self.memory_api_base)
|
|
882
|
+
|
|
883
|
+
def delete_memory(self, user_id: str, memory_id: str) -> Optional[Dict[str, Any]]:
|
|
884
|
+
"""
|
|
885
|
+
Delete a single memory.
|
|
886
|
+
|
|
887
|
+
Args:
|
|
888
|
+
user_id: User who owns the memory.
|
|
889
|
+
memory_id: The memory identifier to delete.
|
|
890
|
+
|
|
891
|
+
Returns:
|
|
892
|
+
{"status": "deleted", "memory_id": str}
|
|
893
|
+
"""
|
|
894
|
+
endpoint, payload = self._prepare_delete_memory(user_id, memory_id)
|
|
895
|
+
return self._request_post_json(endpoint, payload, api_base=self.memory_api_base)
|
|
896
|
+
|
|
897
|
+
def delete_all_memories(self, user_id: str) -> Optional[Dict[str, Any]]:
|
|
898
|
+
"""
|
|
899
|
+
Delete all memories for a user.
|
|
900
|
+
|
|
901
|
+
Args:
|
|
902
|
+
user_id: User whose memories to delete.
|
|
903
|
+
|
|
904
|
+
Returns:
|
|
905
|
+
{"status": "deleted", "count": int}
|
|
906
|
+
"""
|
|
907
|
+
endpoint, payload = self._prepare_delete_all_memories(user_id)
|
|
908
|
+
return self._request_post_json(endpoint, payload, api_base=self.memory_api_base)
|
|
909
|
+
|
|
910
|
+
def update_memory(self, user_id: str, memory_id: str, content: str) -> Optional[Dict[str, Any]]:
|
|
911
|
+
"""
|
|
912
|
+
Update a memory's content.
|
|
913
|
+
|
|
914
|
+
Preserves the memory_id. Re-embeds the new content and updates
|
|
915
|
+
both Redis and the MariaDB audit record.
|
|
916
|
+
|
|
917
|
+
Args:
|
|
918
|
+
user_id: User who owns the memory.
|
|
919
|
+
memory_id: The memory identifier to update.
|
|
920
|
+
content: New memory text.
|
|
921
|
+
|
|
922
|
+
Returns:
|
|
923
|
+
{"status": "updated", "memory_id": str}
|
|
924
|
+
"""
|
|
925
|
+
endpoint, payload = self._prepare_update_memory(user_id, memory_id, content)
|
|
926
|
+
return self._request_post_json(endpoint, payload, api_base=self.memory_api_base)
|
|
927
|
+
|
|
928
|
+
def get_memory_stats(self, user_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
|
929
|
+
"""
|
|
930
|
+
Get memory statistics.
|
|
931
|
+
|
|
932
|
+
Args:
|
|
933
|
+
user_id: Optional user filter.
|
|
934
|
+
|
|
935
|
+
Returns:
|
|
936
|
+
{"total": int, "by_type": {...}, "total_accesses": int}
|
|
937
|
+
"""
|
|
938
|
+
endpoint, params = self._prepare_get_memory_stats(user_id)
|
|
939
|
+
return self._request_get(endpoint, params, api_base=self.memory_api_base)
|
|
940
|
+
|
|
941
|
+
def get_memory_summary(
|
|
942
|
+
self,
|
|
943
|
+
user_id: str,
|
|
944
|
+
force: bool = False,
|
|
945
|
+
timeout: Optional[float] = None,
|
|
946
|
+
) -> Optional[Dict[str, Any]]:
|
|
947
|
+
"""
|
|
948
|
+
Get AI-generated narrative summary of user's memories.
|
|
949
|
+
|
|
950
|
+
Args:
|
|
951
|
+
user_id: User whose memory summary to generate.
|
|
952
|
+
force: Bypass cache and regenerate.
|
|
953
|
+
timeout: Optional request timeout in seconds.
|
|
954
|
+
|
|
955
|
+
Returns:
|
|
956
|
+
{"summary": str | None, "cached": bool, "reason": str | None}
|
|
957
|
+
"""
|
|
958
|
+
endpoint, params = self._prepare_get_memory_summary(user_id, force)
|
|
959
|
+
return self._request_get(endpoint, params, timeout=timeout, api_base=self.memory_api_base)
|
|
960
|
+
|
|
961
|
+
# =========================================================================
|
|
962
|
+
# Shared Knowledge APIs
|
|
963
|
+
# These methods route through memory_api_base -> assistant_runtime_memory.api
|
|
964
|
+
# =========================================================================
|
|
965
|
+
|
|
966
|
+
def get_shared_knowledge(self) -> Optional[Dict[str, Any]]:
|
|
967
|
+
"""
|
|
968
|
+
Get shared knowledge document for the tenant.
|
|
969
|
+
|
|
970
|
+
Returns:
|
|
971
|
+
{"content": str, "embedding_status": str, "total_chunks": int, ...}
|
|
972
|
+
"""
|
|
973
|
+
endpoint, params = self._prepare_get_shared_knowledge()
|
|
974
|
+
return self._request_get(endpoint, params, api_base=self.memory_api_base)
|
|
975
|
+
|
|
976
|
+
def update_shared_knowledge(self, content: str) -> Optional[Dict[str, Any]]:
|
|
977
|
+
"""
|
|
978
|
+
Update shared knowledge content. Triggers re-embedding via RAG pipeline.
|
|
979
|
+
|
|
980
|
+
Args:
|
|
981
|
+
content: New markdown content.
|
|
982
|
+
|
|
983
|
+
Returns:
|
|
984
|
+
{"status": "updated", "embedding_status": str}
|
|
985
|
+
"""
|
|
986
|
+
endpoint, payload = self._prepare_update_shared_knowledge(content)
|
|
987
|
+
return self._request_post_json(endpoint, payload, api_base=self.memory_api_base)
|
|
988
|
+
|
|
989
|
+
def share_memory_to_knowledge(self, user_id: str, memory_id: str) -> Optional[Dict[str, Any]]:
|
|
990
|
+
"""
|
|
991
|
+
Share a personal memory to the shared knowledge document.
|
|
992
|
+
|
|
993
|
+
Appends the memory content with attribution, then re-embeds.
|
|
994
|
+
|
|
995
|
+
Args:
|
|
996
|
+
user_id: User sharing the memory.
|
|
997
|
+
memory_id: AR Memory ID to share.
|
|
998
|
+
|
|
999
|
+
Returns:
|
|
1000
|
+
{"status": "shared", "embedding_status": str}
|
|
1001
|
+
"""
|
|
1002
|
+
endpoint, payload = self._prepare_share_memory_to_knowledge(user_id, memory_id)
|
|
1003
|
+
return self._request_post_json(endpoint, payload, api_base=self.memory_api_base)
|
|
1004
|
+
|
|
1005
|
+
# =========================================================================
|
|
1006
|
+
# Resource APIs (Skills/Documentation)
|
|
1007
|
+
# =========================================================================
|
|
1008
|
+
|
|
1009
|
+
def list_resources(
|
|
1010
|
+
self,
|
|
1011
|
+
user_id: str,
|
|
1012
|
+
server: Optional[str] = None,
|
|
1013
|
+
) -> Optional[Dict[str, Any]]:
|
|
1014
|
+
"""
|
|
1015
|
+
List available resources (skills/documentation) from user's MCP servers.
|
|
1016
|
+
|
|
1017
|
+
Resources are typically tool documentation that can be fetched on-demand
|
|
1018
|
+
to reduce LLM context usage. When the MCP server has resources enabled,
|
|
1019
|
+
tools have minimal descriptions and detailed docs are served as resources.
|
|
1020
|
+
|
|
1021
|
+
Args:
|
|
1022
|
+
user_id: User identifier (required)
|
|
1023
|
+
server: Optional - filter to specific MCP server
|
|
1024
|
+
|
|
1025
|
+
Returns:
|
|
1026
|
+
Dict with resources list, servers_queried, and any errors
|
|
1027
|
+
|
|
1028
|
+
Example:
|
|
1029
|
+
>>> resources = client.list_resources("user@example.com")
|
|
1030
|
+
>>> for r in resources.get("resources", []):
|
|
1031
|
+
... print(f"{r['name']}: {r['uri']}")
|
|
1032
|
+
"""
|
|
1033
|
+
params = self._prepare_resource_params(user_id, server=server)
|
|
1034
|
+
return self._request_get("resources.list_resources", params)
|
|
1035
|
+
|
|
1036
|
+
def read_resource(self, user_id: str, uri: str) -> Optional[Dict[str, Any]]:
|
|
1037
|
+
"""
|
|
1038
|
+
Read a specific resource's content from user's MCP server.
|
|
1039
|
+
|
|
1040
|
+
Args:
|
|
1041
|
+
user_id: User identifier (required)
|
|
1042
|
+
uri: Resource URI to read
|
|
1043
|
+
|
|
1044
|
+
Returns:
|
|
1045
|
+
Dict with resource content
|
|
1046
|
+
|
|
1047
|
+
Example:
|
|
1048
|
+
>>> result = client.read_resource("user@example.com", "fac://tools/create_document")
|
|
1049
|
+
>>> print(result.get("content"))
|
|
1050
|
+
"""
|
|
1051
|
+
params = self._prepare_resource_params(user_id, uri=uri)
|
|
1052
|
+
return self._request_post_json("resources.read_resource", params)
|
|
1053
|
+
|
|
1054
|
+
# =========================================================================
|
|
1055
|
+
# Tool APIs
|
|
1056
|
+
# =========================================================================
|
|
1057
|
+
|
|
1058
|
+
def list_tools(
|
|
1059
|
+
self,
|
|
1060
|
+
user_id: str,
|
|
1061
|
+
server: Optional[str] = None,
|
|
1062
|
+
) -> Optional[Dict[str, Any]]:
|
|
1063
|
+
"""
|
|
1064
|
+
List available tools from user's configured MCP servers.
|
|
1065
|
+
|
|
1066
|
+
Args:
|
|
1067
|
+
user_id: User identifier (required)
|
|
1068
|
+
server: Optional - filter to specific MCP server
|
|
1069
|
+
|
|
1070
|
+
Returns:
|
|
1071
|
+
Dict with tools list, servers_queried, and any errors
|
|
1072
|
+
|
|
1073
|
+
Example:
|
|
1074
|
+
>>> tools = client.list_tools("user@example.com")
|
|
1075
|
+
>>> for t in tools.get("tools", []):
|
|
1076
|
+
... print(f"{t['name']}: {t['description']}")
|
|
1077
|
+
"""
|
|
1078
|
+
endpoint, params = self._prepare_list_tools(user_id, server)
|
|
1079
|
+
return self._request_get(endpoint, params)
|
|
1080
|
+
|
|
1081
|
+
# =========================================================================
|
|
1082
|
+
# Tool Preference APIs (per-user approval settings)
|
|
1083
|
+
# =========================================================================
|
|
1084
|
+
|
|
1085
|
+
def list_tool_preferences(self, user_id: str) -> Optional[Dict[str, Any]]:
|
|
1086
|
+
"""
|
|
1087
|
+
List persistent tool approval preferences for a user.
|
|
1088
|
+
|
|
1089
|
+
Args:
|
|
1090
|
+
user_id: User identifier
|
|
1091
|
+
|
|
1092
|
+
Returns:
|
|
1093
|
+
{"preferences": {tool_name: "always_allow" | "block", ...}}
|
|
1094
|
+
"""
|
|
1095
|
+
endpoint, params = self._prepare_list_tool_preferences(user_id)
|
|
1096
|
+
return self._request_get(endpoint, params)
|
|
1097
|
+
|
|
1098
|
+
def set_tool_preference(
|
|
1099
|
+
self,
|
|
1100
|
+
user_id: str,
|
|
1101
|
+
tool_name: str,
|
|
1102
|
+
preference: str,
|
|
1103
|
+
) -> Optional[Dict[str, Any]]:
|
|
1104
|
+
"""
|
|
1105
|
+
Set a user's approval preference for one tool.
|
|
1106
|
+
|
|
1107
|
+
Args:
|
|
1108
|
+
user_id: User identifier
|
|
1109
|
+
tool_name: MCP tool name (e.g. "create_document")
|
|
1110
|
+
preference: One of "ask", "always_allow", "block"
|
|
1111
|
+
|
|
1112
|
+
Returns:
|
|
1113
|
+
{"success": True, "tool_name": ..., "preference": ...}
|
|
1114
|
+
"""
|
|
1115
|
+
endpoint, payload = self._prepare_set_tool_preference(user_id, tool_name, preference)
|
|
1116
|
+
return self._request_post_json(endpoint, payload)
|
|
1117
|
+
|
|
1118
|
+
# =========================================================================
|
|
1119
|
+
# Billing & Subscription APIs
|
|
1120
|
+
# =========================================================================
|
|
1121
|
+
# These methods route through billing_api_base -> assistant_runtime_payments.api
|
|
1122
|
+
|
|
1123
|
+
def check_billing_available(self) -> bool:
|
|
1124
|
+
"""
|
|
1125
|
+
Probe the server to check if billing features are available.
|
|
1126
|
+
|
|
1127
|
+
Calls ``get_capabilities`` on the core API and checks the
|
|
1128
|
+
``billing_enabled`` flag. The result is cached in ``_billing_available``
|
|
1129
|
+
so subsequent billing method calls can fail fast via ``_require_billing()``.
|
|
1130
|
+
|
|
1131
|
+
Returns:
|
|
1132
|
+
True if billing is available, False otherwise.
|
|
1133
|
+
"""
|
|
1134
|
+
try:
|
|
1135
|
+
url = f"{self.api_base}.get_capabilities"
|
|
1136
|
+
response = requests.get(url, timeout=self.timeout)
|
|
1137
|
+
response.raise_for_status()
|
|
1138
|
+
data = response.json().get("message", response.json())
|
|
1139
|
+
self._billing_available = data.get("billing_enabled", False)
|
|
1140
|
+
except requests.exceptions.RequestException:
|
|
1141
|
+
self._billing_available = False
|
|
1142
|
+
return self._billing_available
|
|
1143
|
+
|
|
1144
|
+
def get_plan_comparison(self) -> Optional[Dict[str, Any]]:
|
|
1145
|
+
"""Get comparison of all available subscription plans (no auth required)."""
|
|
1146
|
+
self._require_billing()
|
|
1147
|
+
url = self._build_billing_endpoint_url("get_plan_comparison")
|
|
1148
|
+
try:
|
|
1149
|
+
response = requests.get(url, timeout=self.timeout)
|
|
1150
|
+
response.raise_for_status()
|
|
1151
|
+
return response.json().get("message", response.json())
|
|
1152
|
+
except requests.exceptions.RequestException as e:
|
|
1153
|
+
self._log_error(f"get_plan_comparison error: {e}")
|
|
1154
|
+
return None
|
|
1155
|
+
|
|
1156
|
+
def get_recommended_gateway(self) -> Optional[Dict[str, Any]]:
|
|
1157
|
+
"""Get the recommended payment gateway for this tenant."""
|
|
1158
|
+
endpoint, params = self._prepare_get_recommended_gateway()
|
|
1159
|
+
return self._request_get(endpoint, params, api_base=self.billing_api_base)
|
|
1160
|
+
|
|
1161
|
+
def get_available_gateways(self) -> Optional[Dict[str, Any]]:
|
|
1162
|
+
"""
|
|
1163
|
+
Get all enabled payment gateways with pricing for each plan.
|
|
1164
|
+
|
|
1165
|
+
Returns:
|
|
1166
|
+
Dict with gateways list, recommended_gateway, and tenant_country.
|
|
1167
|
+
"""
|
|
1168
|
+
endpoint, params = self._prepare_get_available_gateways()
|
|
1169
|
+
return self._request_get(endpoint, params, api_base=self.billing_api_base)
|
|
1170
|
+
|
|
1171
|
+
def preview_plan_pricing(
|
|
1172
|
+
self, plan: str, billing_cycle: str = "monthly",
|
|
1173
|
+
) -> Optional[Dict[str, Any]]:
|
|
1174
|
+
"""Return the tax-inclusive pricing breakdown for a plan.
|
|
1175
|
+
|
|
1176
|
+
Use this to show the user "subtotal + GST = total due today"
|
|
1177
|
+
before opening the Razorpay widget. No DB writes, no Razorpay
|
|
1178
|
+
calls — just runs the tax pipeline for the current tenant.
|
|
1179
|
+
|
|
1180
|
+
Returns:
|
|
1181
|
+
{base, tax, total, currency, tax_rate_percent, components}.
|
|
1182
|
+
"""
|
|
1183
|
+
endpoint, params = self._prepare_preview_plan_pricing(plan, billing_cycle)
|
|
1184
|
+
return self._request_get(endpoint, params, api_base=self.billing_api_base)
|
|
1185
|
+
|
|
1186
|
+
def add_user_seat(self) -> Optional[Dict[str, Any]]:
|
|
1187
|
+
"""Add one seat to the per-user subscription and charge prorated amount."""
|
|
1188
|
+
endpoint, params = self._prepare_add_user_seat()
|
|
1189
|
+
return self._request_get(endpoint, params, api_base=self.billing_api_base)
|
|
1190
|
+
|
|
1191
|
+
def remove_user_seat(self) -> Optional[Dict[str, Any]]:
|
|
1192
|
+
"""Remove one seat. No refund; next renewal reflects lower count."""
|
|
1193
|
+
endpoint, params = self._prepare_remove_user_seat()
|
|
1194
|
+
return self._request_get(endpoint, params, api_base=self.billing_api_base)
|
|
1195
|
+
|
|
1196
|
+
def preview_seat_charge(self) -> Optional[Dict[str, Any]]:
|
|
1197
|
+
"""Preview the prorated cost of adding one seat."""
|
|
1198
|
+
endpoint, params = self._prepare_preview_seat_charge()
|
|
1199
|
+
return self._request_get(endpoint, params, api_base=self.billing_api_base)
|
|
1200
|
+
|
|
1201
|
+
def download_invoice_pdf(self, ar_invoice_name: str) -> tuple:
|
|
1202
|
+
"""Download the GST invoice PDF for an AR Invoice.
|
|
1203
|
+
|
|
1204
|
+
Returns the raw bytes so callers can stream to a file / HTTP
|
|
1205
|
+
response / bucket without buffering through JSON.
|
|
1206
|
+
|
|
1207
|
+
Returns:
|
|
1208
|
+
(content_bytes, content_type, filename) tuple. Backend
|
|
1209
|
+
sets `filename = "{ar_invoice_name}.pdf"` and streams
|
|
1210
|
+
`application/pdf`.
|
|
1211
|
+
"""
|
|
1212
|
+
endpoint, params = self._prepare_download_invoice_pdf(ar_invoice_name)
|
|
1213
|
+
return self._request_get_raw(
|
|
1214
|
+
endpoint, params, api_base=self.billing_api_base,
|
|
1215
|
+
)
|
|
1216
|
+
|
|
1217
|
+
def initiate_checkout(
|
|
1218
|
+
self,
|
|
1219
|
+
plan: str,
|
|
1220
|
+
billing_cycle: str = "monthly",
|
|
1221
|
+
gateway: Optional[str] = None,
|
|
1222
|
+
billing_name: Optional[str] = None,
|
|
1223
|
+
billing_email: Optional[str] = None,
|
|
1224
|
+
) -> Optional[Dict[str, Any]]:
|
|
1225
|
+
"""
|
|
1226
|
+
Create a checkout session for subscription upgrade.
|
|
1227
|
+
|
|
1228
|
+
Args:
|
|
1229
|
+
plan: Plan name (Starter, Pro, Enterprise)
|
|
1230
|
+
billing_cycle: "monthly" or "annual" (default: monthly)
|
|
1231
|
+
gateway: "stripe" or "razorpay" (optional)
|
|
1232
|
+
billing_name: Customer/company name for billing
|
|
1233
|
+
billing_email: Email for billing notifications
|
|
1234
|
+
|
|
1235
|
+
Returns:
|
|
1236
|
+
Dict with checkout_url, session_id, and gateway used.
|
|
1237
|
+
"""
|
|
1238
|
+
endpoint, payload = self._prepare_initiate_checkout(plan, billing_cycle, gateway, billing_name, billing_email)
|
|
1239
|
+
return self._request_post_json(endpoint, payload, api_base=self.billing_api_base)
|
|
1240
|
+
|
|
1241
|
+
def verify_checkout(self, session_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
|
1242
|
+
"""Verify payment completion after checkout."""
|
|
1243
|
+
endpoint, payload = self._prepare_verify_checkout(session_id)
|
|
1244
|
+
return self._request_post_json(endpoint, payload, api_base=self.billing_api_base)
|
|
1245
|
+
|
|
1246
|
+
def verify_razorpay_payment(
|
|
1247
|
+
self,
|
|
1248
|
+
razorpay_payment_id: str,
|
|
1249
|
+
razorpay_subscription_id: str,
|
|
1250
|
+
razorpay_signature: str,
|
|
1251
|
+
) -> Optional[Dict[str, Any]]:
|
|
1252
|
+
"""
|
|
1253
|
+
Verify Razorpay embedded checkout payment signature.
|
|
1254
|
+
|
|
1255
|
+
Args:
|
|
1256
|
+
razorpay_payment_id: Payment ID from Razorpay widget response
|
|
1257
|
+
razorpay_subscription_id: Subscription ID from Razorpay widget response
|
|
1258
|
+
razorpay_signature: Signature from Razorpay widget response
|
|
1259
|
+
|
|
1260
|
+
Returns:
|
|
1261
|
+
{"success": True, "message": str, "subscription_status": str, "plan": str, "credit_quota": int}
|
|
1262
|
+
"""
|
|
1263
|
+
endpoint, payload = self._prepare_verify_razorpay_payment(razorpay_payment_id, razorpay_subscription_id, razorpay_signature)
|
|
1264
|
+
return self._request_post_json(endpoint, payload, api_base=self.billing_api_base)
|
|
1265
|
+
|
|
1266
|
+
def verify_razorpay_credit_payment(
|
|
1267
|
+
self,
|
|
1268
|
+
razorpay_payment_id: str,
|
|
1269
|
+
razorpay_order_id: str,
|
|
1270
|
+
razorpay_signature: str,
|
|
1271
|
+
) -> Optional[Dict[str, Any]]:
|
|
1272
|
+
"""
|
|
1273
|
+
Verify Razorpay credit (token top-up) payment from embedded widget.
|
|
1274
|
+
|
|
1275
|
+
Args:
|
|
1276
|
+
razorpay_payment_id: Payment ID from Razorpay widget response
|
|
1277
|
+
razorpay_order_id: Order ID from Razorpay widget response
|
|
1278
|
+
razorpay_signature: Signature from Razorpay widget response
|
|
1279
|
+
|
|
1280
|
+
Returns:
|
|
1281
|
+
{"success": True, "balance": int, "tokens_added": int, "message": str}
|
|
1282
|
+
"""
|
|
1283
|
+
endpoint, payload = self._prepare_verify_razorpay_credit_payment(razorpay_payment_id, razorpay_order_id, razorpay_signature)
|
|
1284
|
+
return self._request_post_json(endpoint, payload, api_base=self.billing_api_base)
|
|
1285
|
+
|
|
1286
|
+
def verify_seat_payment(
|
|
1287
|
+
self,
|
|
1288
|
+
razorpay_payment_id: str,
|
|
1289
|
+
razorpay_order_id: str,
|
|
1290
|
+
razorpay_signature: str,
|
|
1291
|
+
) -> Optional[Dict[str, Any]]:
|
|
1292
|
+
"""Verify a Razorpay seat-purchase payment from the embedded widget.
|
|
1293
|
+
|
|
1294
|
+
Args:
|
|
1295
|
+
razorpay_payment_id: Payment ID from Razorpay widget response.
|
|
1296
|
+
razorpay_order_id: Order ID from Razorpay widget response.
|
|
1297
|
+
razorpay_signature: Signature from Razorpay widget response.
|
|
1298
|
+
|
|
1299
|
+
Returns:
|
|
1300
|
+
``{"success": True, "user_count": int, "invoice": str|None, "message": str}``
|
|
1301
|
+
"""
|
|
1302
|
+
endpoint, payload = self._prepare_verify_seat_payment(
|
|
1303
|
+
razorpay_payment_id, razorpay_order_id, razorpay_signature
|
|
1304
|
+
)
|
|
1305
|
+
return self._request_post_json(endpoint, payload, api_base=self.billing_api_base)
|
|
1306
|
+
|
|
1307
|
+
def get_token_analytics(self, days: int = 30, user_id: str = None) -> Optional[Dict[str, Any]]:
|
|
1308
|
+
"""Get per-user/model/source token usage analytics for dashboard.
|
|
1309
|
+
|
|
1310
|
+
Queries AR Token Usage (AR core) for aggregated breakdowns.
|
|
1311
|
+
When user_id is provided, results are scoped to that user only.
|
|
1312
|
+
|
|
1313
|
+
Args:
|
|
1314
|
+
days: Number of days to query (7, 30, or 90)
|
|
1315
|
+
user_id: Optional user filter for scoping results
|
|
1316
|
+
|
|
1317
|
+
Returns:
|
|
1318
|
+
Analytics data with summary, daily, by_user, by_model, by_source
|
|
1319
|
+
"""
|
|
1320
|
+
endpoint, payload = self._prepare_get_token_analytics(days, user_id)
|
|
1321
|
+
return self._request_post_json(endpoint, payload)
|
|
1322
|
+
|
|
1323
|
+
def get_conversation_analytics(
|
|
1324
|
+
self, user_id: str = None, days: int = 30, limit: int = 50, offset: int = 0,
|
|
1325
|
+
) -> Optional[Dict[str, Any]]:
|
|
1326
|
+
"""Get per-conversation credit breakdown for analytics drill-down.
|
|
1327
|
+
|
|
1328
|
+
Args:
|
|
1329
|
+
user_id: Optional user filter for privacy enforcement
|
|
1330
|
+
days: Number of days to query (7, 30, or 90)
|
|
1331
|
+
limit: Page size (default: 50)
|
|
1332
|
+
offset: Pagination offset (default: 0)
|
|
1333
|
+
|
|
1334
|
+
Returns:
|
|
1335
|
+
Conversations with credits, summary totals, pagination
|
|
1336
|
+
"""
|
|
1337
|
+
endpoint, payload = self._prepare_get_conversation_analytics(user_id, days, limit, offset)
|
|
1338
|
+
return self._request_post_json(endpoint, payload)
|
|
1339
|
+
|
|
1340
|
+
def get_message_credits(self, conversation_id: str, user_id: str = None) -> Optional[Dict[str, Any]]:
|
|
1341
|
+
"""Get per-message credit breakdown for a single conversation.
|
|
1342
|
+
|
|
1343
|
+
Args:
|
|
1344
|
+
conversation_id: The conversation to drill into
|
|
1345
|
+
user_id: Optional user filter for privacy enforcement
|
|
1346
|
+
|
|
1347
|
+
Returns:
|
|
1348
|
+
Messages with credits, model info, and content previews
|
|
1349
|
+
"""
|
|
1350
|
+
endpoint, payload = self._prepare_get_message_credits(conversation_id, user_id)
|
|
1351
|
+
return self._request_post_json(endpoint, payload)
|
|
1352
|
+
|
|
1353
|
+
def get_usage_dashboard(self) -> Optional[Dict[str, Any]]:
|
|
1354
|
+
"""Get comprehensive usage and billing data for dashboard."""
|
|
1355
|
+
endpoint, payload = self._prepare_get_usage_dashboard()
|
|
1356
|
+
return self._request_post_json(endpoint, payload, api_base=self.billing_api_base)
|
|
1357
|
+
|
|
1358
|
+
def get_usage_history(self, days: int = 30) -> Optional[Dict[str, Any]]:
|
|
1359
|
+
"""Get historical usage data for charts."""
|
|
1360
|
+
endpoint, payload = self._prepare_get_usage_history(days)
|
|
1361
|
+
return self._request_post_json(endpoint, payload, api_base=self.billing_api_base)
|
|
1362
|
+
|
|
1363
|
+
def get_invoices(self, limit: int = 10) -> Optional[Dict[str, Any]]:
|
|
1364
|
+
"""Get invoice history."""
|
|
1365
|
+
endpoint, payload = self._prepare_get_invoices(limit)
|
|
1366
|
+
return self._request_post_json(endpoint, payload, api_base=self.billing_api_base)
|
|
1367
|
+
|
|
1368
|
+
def get_upcoming_invoice(self) -> Optional[Dict[str, Any]]:
|
|
1369
|
+
"""Get upcoming invoice preview (Stripe only)."""
|
|
1370
|
+
endpoint, payload = self._prepare_get_upcoming_invoice()
|
|
1371
|
+
return self._request_post_json(endpoint, payload, api_base=self.billing_api_base)
|
|
1372
|
+
|
|
1373
|
+
def get_payment_methods(self) -> Optional[Dict[str, Any]]:
|
|
1374
|
+
"""Get saved payment methods."""
|
|
1375
|
+
endpoint, payload = self._prepare_get_payment_methods()
|
|
1376
|
+
return self._request_post_json(endpoint, payload, api_base=self.billing_api_base)
|
|
1377
|
+
|
|
1378
|
+
def upgrade_plan(
|
|
1379
|
+
self,
|
|
1380
|
+
new_plan: str,
|
|
1381
|
+
billing_cycle: str = "monthly",
|
|
1382
|
+
gateway: Optional[str] = None,
|
|
1383
|
+
billing_name: Optional[str] = None,
|
|
1384
|
+
billing_email: Optional[str] = None,
|
|
1385
|
+
promo_code: Optional[str] = None,
|
|
1386
|
+
payment_method: Optional[str] = None,
|
|
1387
|
+
) -> Optional[Dict[str, Any]]:
|
|
1388
|
+
"""
|
|
1389
|
+
Change subscription plan (upgrade or downgrade).
|
|
1390
|
+
|
|
1391
|
+
The API automatically detects whether this is an upgrade or downgrade
|
|
1392
|
+
based on plan hierarchy: Free < Starter < Pro < Enterprise
|
|
1393
|
+
|
|
1394
|
+
Args:
|
|
1395
|
+
new_plan: Plan name ("Free", "Starter", "Pro", "Enterprise")
|
|
1396
|
+
billing_cycle: "monthly" or "annual" (default: monthly)
|
|
1397
|
+
gateway: "stripe" or "razorpay" (optional)
|
|
1398
|
+
billing_name: Customer/company name for billing
|
|
1399
|
+
billing_email: Email for billing notifications
|
|
1400
|
+
promo_code: Promotional/referral code (optional)
|
|
1401
|
+
payment_method: Razorpay-only — "upi" (UPI Autopay) or "card".
|
|
1402
|
+
Defaults to "upi" for India, "card" for international.
|
|
1403
|
+
|
|
1404
|
+
Returns:
|
|
1405
|
+
For upgrades: {"success": True, "message": str, "subscription_id": str}
|
|
1406
|
+
For downgrades: {"success": True, "message": str, "effective_date": str}
|
|
1407
|
+
If checkout needed: {"checkout_url": str, "session_id": str, "gateway": str}
|
|
1408
|
+
"""
|
|
1409
|
+
endpoint, payload = self._prepare_upgrade_plan(
|
|
1410
|
+
new_plan, billing_cycle, gateway, billing_name, billing_email, promo_code,
|
|
1411
|
+
payment_method=payment_method,
|
|
1412
|
+
)
|
|
1413
|
+
return self._request_post_json(endpoint, payload, api_base=self.billing_api_base)
|
|
1414
|
+
|
|
1415
|
+
def reauthorize_mandate(
|
|
1416
|
+
self,
|
|
1417
|
+
billing_name: Optional[str] = None,
|
|
1418
|
+
payment_method: Optional[str] = None,
|
|
1419
|
+
) -> Optional[Dict[str, Any]]:
|
|
1420
|
+
"""Authorize a fresh recurring mandate on the customer's CURRENT plan.
|
|
1421
|
+
|
|
1422
|
+
Used when the renewal cron flagged the saved Razorpay token as
|
|
1423
|
+
exhausted (e.g., projected next-cycle debit > UPI Autopay's
|
|
1424
|
+
per-debit ₹1,00,000 NPCI ceiling), or when the bank cancelled the
|
|
1425
|
+
token. Returns the same Razorpay-checkout shape as ``upgrade_plan``
|
|
1426
|
+
so callers reuse their existing widget-launching code path.
|
|
1427
|
+
"""
|
|
1428
|
+
endpoint, payload = self._prepare_reauthorize_mandate(
|
|
1429
|
+
billing_name=billing_name,
|
|
1430
|
+
payment_method=payment_method,
|
|
1431
|
+
)
|
|
1432
|
+
return self._request_post_json(endpoint, payload, api_base=self.billing_api_base)
|
|
1433
|
+
|
|
1434
|
+
|
|
1435
|
+
def validate_promo_code(
|
|
1436
|
+
self,
|
|
1437
|
+
promo_code: str,
|
|
1438
|
+
plan: Optional[str] = None,
|
|
1439
|
+
timeout: Optional[float] = None,
|
|
1440
|
+
) -> Optional[Dict[str, Any]]:
|
|
1441
|
+
"""
|
|
1442
|
+
Validate a promotional or referral code for this tenant.
|
|
1443
|
+
|
|
1444
|
+
Args:
|
|
1445
|
+
promo_code: The promo/referral code to validate
|
|
1446
|
+
plan: Optional plan name to check code applicability against
|
|
1447
|
+
|
|
1448
|
+
Returns:
|
|
1449
|
+
{"valid": True, "discount_type": str, "discount_value": float, ...}
|
|
1450
|
+
or {"valid": False, "error": str}
|
|
1451
|
+
"""
|
|
1452
|
+
endpoint, payload = self._prepare_validate_promo_code(promo_code, plan)
|
|
1453
|
+
return self._request_post_json(endpoint, payload, api_base=self.billing_api_base, timeout=timeout)
|
|
1454
|
+
|
|
1455
|
+
def downgrade_to_free(self) -> Optional[Dict[str, Any]]:
|
|
1456
|
+
"""
|
|
1457
|
+
Schedule downgrade to the Free plan at end of billing period.
|
|
1458
|
+
|
|
1459
|
+
Returns:
|
|
1460
|
+
{"success": True, "message": str, "effective_date": str}
|
|
1461
|
+
"""
|
|
1462
|
+
endpoint, payload = self._prepare_downgrade_to_free()
|
|
1463
|
+
return self._request_post_json(endpoint, payload, api_base=self.billing_api_base)
|
|
1464
|
+
|
|
1465
|
+
def cancel_scheduled_change(self) -> Optional[Dict[str, Any]]:
|
|
1466
|
+
"""Cancel a pending downgrade that was scheduled for end of billing period."""
|
|
1467
|
+
endpoint, payload = self._prepare_cancel_scheduled_change()
|
|
1468
|
+
return self._request_post_json(endpoint, payload, api_base=self.billing_api_base)
|
|
1469
|
+
|
|
1470
|
+
def cancel_subscription(self, cancel_immediately: bool = False) -> Optional[Dict[str, Any]]:
|
|
1471
|
+
"""Cancel subscription."""
|
|
1472
|
+
endpoint, payload = self._prepare_cancel_subscription(cancel_immediately)
|
|
1473
|
+
return self._request_post_json(endpoint, payload, api_base=self.billing_api_base)
|
|
1474
|
+
|
|
1475
|
+
def reactivate_subscription(self) -> Optional[Dict[str, Any]]:
|
|
1476
|
+
"""Reactivate a subscription that was set to cancel at period end."""
|
|
1477
|
+
endpoint, payload = self._prepare_reactivate_subscription()
|
|
1478
|
+
return self._request_post_json(endpoint, payload, api_base=self.billing_api_base)
|
|
1479
|
+
|
|
1480
|
+
def pause_subscription(self) -> Optional[Dict[str, Any]]:
|
|
1481
|
+
"""Pause subscription (Razorpay only)."""
|
|
1482
|
+
endpoint, payload = self._prepare_pause_subscription()
|
|
1483
|
+
return self._request_post_json(endpoint, payload, api_base=self.billing_api_base)
|
|
1484
|
+
|
|
1485
|
+
def resume_subscription(self) -> Optional[Dict[str, Any]]:
|
|
1486
|
+
"""Resume a paused subscription (Razorpay only)."""
|
|
1487
|
+
endpoint, payload = self._prepare_resume_subscription()
|
|
1488
|
+
return self._request_post_json(endpoint, payload, api_base=self.billing_api_base)
|
|
1489
|
+
|
|
1490
|
+
def update_payment_method(self) -> Optional[Dict[str, Any]]:
|
|
1491
|
+
"""Get URL to update payment method."""
|
|
1492
|
+
endpoint, payload = self._prepare_update_payment_method()
|
|
1493
|
+
return self._request_post_json(endpoint, payload, api_base=self.billing_api_base)
|
|
1494
|
+
|
|
1495
|
+
def get_subscription_status(self) -> Optional[Dict[str, Any]]:
|
|
1496
|
+
"""
|
|
1497
|
+
Get current subscription status including any scheduled changes.
|
|
1498
|
+
|
|
1499
|
+
Returns:
|
|
1500
|
+
Dict with subscription status including plan, quota, usage, dates,
|
|
1501
|
+
and scheduled_change info.
|
|
1502
|
+
"""
|
|
1503
|
+
endpoint, params = self._prepare_get_subscription_status()
|
|
1504
|
+
return self._request_get(endpoint, params, api_base=self.billing_api_base)
|
|
1505
|
+
|
|
1506
|
+
def get_billing_history(self, limit: int = 20) -> Optional[Dict[str, Any]]:
|
|
1507
|
+
"""
|
|
1508
|
+
Get payment/billing history for this tenant.
|
|
1509
|
+
|
|
1510
|
+
Args:
|
|
1511
|
+
limit: Maximum number of records to return (default: 20)
|
|
1512
|
+
|
|
1513
|
+
Returns:
|
|
1514
|
+
Dict with billing history list and optional portal_url (Stripe only).
|
|
1515
|
+
"""
|
|
1516
|
+
endpoint, params = self._prepare_get_billing_history(limit)
|
|
1517
|
+
return self._request_get(endpoint, params, api_base=self.billing_api_base)
|
|
1518
|
+
|
|
1519
|
+
def get_billing_details(self) -> Optional[Dict[str, Any]]:
|
|
1520
|
+
"""
|
|
1521
|
+
Get the tenant's billing identity (email, phone, GSTIN, address).
|
|
1522
|
+
|
|
1523
|
+
Returns:
|
|
1524
|
+
Dict with billing_email, billing_phone, gstin, billing_address_line1,
|
|
1525
|
+
billing_address_line2, billing_city, billing_state, billing_pincode,
|
|
1526
|
+
billing_country. Empty strings for fields not yet configured.
|
|
1527
|
+
"""
|
|
1528
|
+
endpoint, params = self._prepare_get_billing_details()
|
|
1529
|
+
return self._request_get(endpoint, params, api_base=self.billing_api_base)
|
|
1530
|
+
|
|
1531
|
+
def save_billing_details(self, **billing_fields: Any) -> Optional[Dict[str, Any]]:
|
|
1532
|
+
"""
|
|
1533
|
+
Upsert the tenant's billing identity on the ERPNext Customer + Address.
|
|
1534
|
+
|
|
1535
|
+
Required: billing_email, billing_country.
|
|
1536
|
+
Optional: gstin, billing_state, billing_city, billing_pincode,
|
|
1537
|
+
billing_address_line1, billing_address_line2, billing_phone.
|
|
1538
|
+
|
|
1539
|
+
Returns:
|
|
1540
|
+
Dict with the saved values (server-canonicalised: country name,
|
|
1541
|
+
normalised GSTIN, derived gst_category, etc.).
|
|
1542
|
+
"""
|
|
1543
|
+
endpoint, payload = self._prepare_save_billing_details(billing_fields)
|
|
1544
|
+
return self._request_post_json(endpoint, payload, api_base=self.billing_api_base)
|
|
1545
|
+
|
|
1546
|
+
# =========================================================================
|
|
1547
|
+
# Prepaid Credit APIs
|
|
1548
|
+
# =========================================================================
|
|
1549
|
+
|
|
1550
|
+
def get_credit_balance(self) -> Optional[Dict[str, Any]]:
|
|
1551
|
+
"""
|
|
1552
|
+
Get prepaid credit balance and recent transaction history.
|
|
1553
|
+
|
|
1554
|
+
Returns:
|
|
1555
|
+
Dict with ``balance`` (int) and ``transactions`` (list).
|
|
1556
|
+
"""
|
|
1557
|
+
endpoint, params = self._prepare_get_credit_balance()
|
|
1558
|
+
return self._request_get(endpoint, params, api_base=self.billing_api_base)
|
|
1559
|
+
|
|
1560
|
+
def purchase_credits(
|
|
1561
|
+
self, credit_amount: int, gateway: str = None
|
|
1562
|
+
) -> Optional[Dict[str, Any]]:
|
|
1563
|
+
"""
|
|
1564
|
+
Create a one-time checkout session for purchasing prepaid credits.
|
|
1565
|
+
|
|
1566
|
+
Args:
|
|
1567
|
+
credit_amount: Number of credits to purchase. Pricing is flat —
|
|
1568
|
+
``(credit_amount / pack_credits) * pack_price`` per the
|
|
1569
|
+
AR Payment Gateway Settings.
|
|
1570
|
+
gateway: "stripe" or "razorpay" (optional, auto-selects).
|
|
1571
|
+
|
|
1572
|
+
Returns:
|
|
1573
|
+
Gateway-specific checkout data (checkout URL or order ID).
|
|
1574
|
+
"""
|
|
1575
|
+
endpoint, payload = self._prepare_purchase_credits(credit_amount, gateway)
|
|
1576
|
+
return self._request_post_json(endpoint, payload, api_base=self.billing_api_base)
|
|
1577
|
+
|
|
1578
|
+
def get_expiring_credits(self) -> Optional[Dict[str, Any]]:
|
|
1579
|
+
"""List unallocated credit batches expiring within 7 days."""
|
|
1580
|
+
endpoint, params = self._prepare_get_expiring_credits()
|
|
1581
|
+
return self._request_get(endpoint, params, api_base=self.billing_api_base)
|
|
1582
|
+
|
|
1583
|
+
def get_consumption_breakdown(self, days: int = 30) -> Optional[Dict[str, Any]]:
|
|
1584
|
+
"""Daily credit consumption rollup grouped by source."""
|
|
1585
|
+
endpoint, params = self._prepare_get_consumption_breakdown(days)
|
|
1586
|
+
return self._request_get(endpoint, params, api_base=self.billing_api_base)
|
|
1587
|
+
|
|
1588
|
+
# =========================================================================
|
|
1589
|
+
# Conversation APIs
|
|
1590
|
+
# =========================================================================
|
|
1591
|
+
|
|
1592
|
+
def list_conversations(
|
|
1593
|
+
self,
|
|
1594
|
+
user_id: Optional[str] = None,
|
|
1595
|
+
limit: int = 50,
|
|
1596
|
+
offset: int = 0,
|
|
1597
|
+
include_deleted: bool = False,
|
|
1598
|
+
from_date: Optional[str] = None,
|
|
1599
|
+
to_date: Optional[str] = None,
|
|
1600
|
+
) -> Optional[Dict[str, Any]]:
|
|
1601
|
+
"""List conversations for tenant, optionally filtered by user_id."""
|
|
1602
|
+
endpoint, params = self._prepare_list_conversations(user_id, limit, offset, include_deleted, from_date, to_date)
|
|
1603
|
+
return self._request_get(endpoint, params)
|
|
1604
|
+
|
|
1605
|
+
def get_conversation(self, conversation_id: str) -> Optional[Dict[str, Any]]:
|
|
1606
|
+
"""Get detailed information about a specific conversation."""
|
|
1607
|
+
endpoint, params = self._prepare_get_conversation(conversation_id)
|
|
1608
|
+
return self._request_get(endpoint, params)
|
|
1609
|
+
|
|
1610
|
+
def get_messages(
|
|
1611
|
+
self,
|
|
1612
|
+
conversation_id: str,
|
|
1613
|
+
limit: int = 100,
|
|
1614
|
+
offset: int = 0,
|
|
1615
|
+
include_deleted: bool = False,
|
|
1616
|
+
) -> Optional[Dict[str, Any]]:
|
|
1617
|
+
"""Get messages for a specific conversation with pagination."""
|
|
1618
|
+
endpoint, params = self._prepare_get_messages(conversation_id, limit, offset, include_deleted)
|
|
1619
|
+
return self._request_get(endpoint, params)
|
|
1620
|
+
|
|
1621
|
+
def create_message(
|
|
1622
|
+
self,
|
|
1623
|
+
conversation_id: str,
|
|
1624
|
+
message_id: str,
|
|
1625
|
+
role: str,
|
|
1626
|
+
content: str,
|
|
1627
|
+
user_id: Optional[str] = None,
|
|
1628
|
+
tokens_used: int = 0,
|
|
1629
|
+
context: Optional[Dict[str, Any]] = None,
|
|
1630
|
+
) -> Optional[Dict[str, Any]]:
|
|
1631
|
+
"""Create a new message in a conversation."""
|
|
1632
|
+
endpoint, payload = self._prepare_create_message(conversation_id, message_id, role, content, user_id, tokens_used, context)
|
|
1633
|
+
return self._request_post_json(endpoint, payload)
|
|
1634
|
+
|
|
1635
|
+
def update_conversation(
|
|
1636
|
+
self,
|
|
1637
|
+
conversation_id: str,
|
|
1638
|
+
title: Optional[str] = None,
|
|
1639
|
+
user_id: Optional[str] = None,
|
|
1640
|
+
) -> Optional[Dict[str, Any]]:
|
|
1641
|
+
"""Update conversation title or user_id."""
|
|
1642
|
+
endpoint, payload = self._prepare_update_conversation(conversation_id, title, user_id)
|
|
1643
|
+
return self._request_post_json(endpoint, payload)
|
|
1644
|
+
|
|
1645
|
+
def delete_conversation(self, conversation_id: str, hard_delete: bool = False) -> Optional[Dict[str, Any]]:
|
|
1646
|
+
"""Delete a conversation. Use hard_delete=True for permanent GDPR erasure."""
|
|
1647
|
+
endpoint, payload = self._prepare_delete_conversation(conversation_id, hard_delete)
|
|
1648
|
+
return self._request_post_json(endpoint, payload)
|
|
1649
|
+
|
|
1650
|
+
def delete_message(self, conversation_id: str, message_id: str) -> Optional[Dict[str, Any]]:
|
|
1651
|
+
"""Soft delete a specific message."""
|
|
1652
|
+
endpoint, payload = self._prepare_delete_message(conversation_id, message_id)
|
|
1653
|
+
return self._request_post_json(endpoint, payload)
|
|
1654
|
+
|
|
1655
|
+
def get_sync_stats(self) -> Optional[Dict[str, Any]]:
|
|
1656
|
+
"""Get synchronization statistics for the tenant."""
|
|
1657
|
+
endpoint, params = self._prepare_get_sync_stats()
|
|
1658
|
+
return self._request_get(endpoint, params)
|
|
1659
|
+
|
|
1660
|
+
# =========================================================================
|
|
1661
|
+
# Streaming Events APIs (Historical)
|
|
1662
|
+
# =========================================================================
|
|
1663
|
+
|
|
1664
|
+
def get_message_events(
|
|
1665
|
+
self,
|
|
1666
|
+
conversation_id: str,
|
|
1667
|
+
message_id: Optional[str] = None,
|
|
1668
|
+
event_types: Optional[list] = None,
|
|
1669
|
+
limit: int = 100,
|
|
1670
|
+
offset: int = 0,
|
|
1671
|
+
) -> Optional[Dict[str, Any]]:
|
|
1672
|
+
"""Retrieve persisted streaming events for audit trail."""
|
|
1673
|
+
endpoint, params = self._prepare_get_message_events(conversation_id, message_id, event_types, limit, offset)
|
|
1674
|
+
return self._request_get(endpoint, params)
|
|
1675
|
+
|
|
1676
|
+
def get_tool_execution_stats(
|
|
1677
|
+
self,
|
|
1678
|
+
conversation_id: Optional[str] = None,
|
|
1679
|
+
from_date: Optional[str] = None,
|
|
1680
|
+
to_date: Optional[str] = None,
|
|
1681
|
+
) -> Optional[Dict[str, Any]]:
|
|
1682
|
+
"""Get aggregated tool execution statistics."""
|
|
1683
|
+
endpoint, params = self._prepare_get_tool_execution_stats(conversation_id, from_date, to_date)
|
|
1684
|
+
return self._request_get(endpoint, params)
|
|
1685
|
+
|
|
1686
|
+
# =========================================================================
|
|
1687
|
+
# User & MCP Server APIs
|
|
1688
|
+
# =========================================================================
|
|
1689
|
+
|
|
1690
|
+
def register_user(
|
|
1691
|
+
self,
|
|
1692
|
+
user_id: str,
|
|
1693
|
+
display_name: Optional[str] = None,
|
|
1694
|
+
custom_instructions: Optional[str] = None,
|
|
1695
|
+
locale: Optional[str] = None,
|
|
1696
|
+
timezone: Optional[str] = None,
|
|
1697
|
+
user_role: Optional[str] = None,
|
|
1698
|
+
email: Optional[str] = None,
|
|
1699
|
+
registered_by: Optional[str] = None,
|
|
1700
|
+
) -> Dict[str, Any]:
|
|
1701
|
+
"""Register a user with Assistant Runtime."""
|
|
1702
|
+
endpoint, params = self._prepare_register_user(
|
|
1703
|
+
user_id, display_name, custom_instructions,
|
|
1704
|
+
locale=locale, timezone=timezone, user_role=user_role, email=email,
|
|
1705
|
+
registered_by=registered_by,
|
|
1706
|
+
)
|
|
1707
|
+
return self._request_post_form(endpoint, params)
|
|
1708
|
+
|
|
1709
|
+
def invite_user(
|
|
1710
|
+
self,
|
|
1711
|
+
user_id: str,
|
|
1712
|
+
user_role: Optional[str] = None,
|
|
1713
|
+
invited_by: Optional[str] = None,
|
|
1714
|
+
) -> Dict[str, Any]:
|
|
1715
|
+
"""Invite an existing site user to the workspace as a Pending member.
|
|
1716
|
+
|
|
1717
|
+
The invite reserves a billed seat immediately and auto-activates on the
|
|
1718
|
+
user's first activity. ``invited_by`` is the acting admin's user_id
|
|
1719
|
+
(used for the server-side authority check).
|
|
1720
|
+
"""
|
|
1721
|
+
endpoint, params = self._prepare_invite_user(user_id, user_role, invited_by)
|
|
1722
|
+
return self._request_post_form(endpoint, params)
|
|
1723
|
+
|
|
1724
|
+
def revoke_invite(
|
|
1725
|
+
self,
|
|
1726
|
+
user_id: str,
|
|
1727
|
+
revoked_by: Optional[str] = None,
|
|
1728
|
+
) -> Dict[str, Any]:
|
|
1729
|
+
"""Revoke a Pending invite, freeing its reserved seat.
|
|
1730
|
+
|
|
1731
|
+
Only Pending invites can be revoked; use deregister_user to remove an
|
|
1732
|
+
active member.
|
|
1733
|
+
"""
|
|
1734
|
+
endpoint, params = self._prepare_revoke_invite(user_id, revoked_by)
|
|
1735
|
+
return self._request_post_form(endpoint, params)
|
|
1736
|
+
|
|
1737
|
+
def resend_invite(
|
|
1738
|
+
self,
|
|
1739
|
+
user_id: str,
|
|
1740
|
+
resent_by: Optional[str] = None,
|
|
1741
|
+
) -> Dict[str, Any]:
|
|
1742
|
+
"""Resend a Pending invite, restarting its 7-day expiry window."""
|
|
1743
|
+
endpoint, params = self._prepare_resend_invite(user_id, resent_by)
|
|
1744
|
+
return self._request_post_form(endpoint, params)
|
|
1745
|
+
|
|
1746
|
+
def list_invites(self) -> Dict[str, Any]:
|
|
1747
|
+
"""List all Pending invites for this tenant (with invited-by + timing)."""
|
|
1748
|
+
endpoint, params = self._prepare_list_invites()
|
|
1749
|
+
return self._request_get(endpoint, params)
|
|
1750
|
+
|
|
1751
|
+
def get_member_audit_log(self, limit: int = 100, offset: int = 0) -> Dict[str, Any]:
|
|
1752
|
+
"""Get the member-management audit log (newest first, paginated).
|
|
1753
|
+
|
|
1754
|
+
Returns only member-management actions — never GDPR/append-only rows.
|
|
1755
|
+
Each entry's ``details`` field is a JSON string the caller must parse.
|
|
1756
|
+
"""
|
|
1757
|
+
endpoint, params = self._prepare_get_member_audit_log(limit, offset)
|
|
1758
|
+
return self._request_get(endpoint, params)
|
|
1759
|
+
|
|
1760
|
+
def get_user(self, user_id: str) -> Optional[Dict[str, Any]]:
|
|
1761
|
+
"""Get user details including MCP server count."""
|
|
1762
|
+
endpoint, params = self._prepare_get_user(user_id)
|
|
1763
|
+
return self._request_get(endpoint, params)
|
|
1764
|
+
|
|
1765
|
+
def get_user_auth_status(self, user_id: str) -> Dict[str, Any]:
|
|
1766
|
+
"""Check user authentication status and MCP server readiness.
|
|
1767
|
+
|
|
1768
|
+
On success, returns AR's authoritative response — including the case
|
|
1769
|
+
where AR says the user genuinely doesn't exist (``user_exists: False``).
|
|
1770
|
+
|
|
1771
|
+
On transport failure (network, timeout, 5xx), returns a marker
|
|
1772
|
+
response with ``_ar_unreachable: True`` so callers can distinguish
|
|
1773
|
+
"AR is briefly down" from "AR says this user is unregistered". The
|
|
1774
|
+
previous implementation silently returned ``user_exists: False`` for
|
|
1775
|
+
both cases, which caused FACO to wrongly kick logged-in users to the
|
|
1776
|
+
connect-account screen on transient AR hiccups.
|
|
1777
|
+
"""
|
|
1778
|
+
endpoint, params = self._prepare_get_user_auth_status(user_id)
|
|
1779
|
+
try:
|
|
1780
|
+
return self._request_get(endpoint, params)
|
|
1781
|
+
except Exception as e:
|
|
1782
|
+
return {
|
|
1783
|
+
"_ar_unreachable": True,
|
|
1784
|
+
"error": str(e),
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
def add_user_mcp_server(
|
|
1788
|
+
self,
|
|
1789
|
+
user_id: str,
|
|
1790
|
+
server_name: str,
|
|
1791
|
+
endpoint_url: str,
|
|
1792
|
+
transport_type: str = "SSE",
|
|
1793
|
+
auth_type: str = "OAuth",
|
|
1794
|
+
oauth_client_id: Optional[str] = None,
|
|
1795
|
+
oauth_client_secret: Optional[str] = None,
|
|
1796
|
+
access_token: Optional[str] = None,
|
|
1797
|
+
refresh_token: Optional[str] = None,
|
|
1798
|
+
token_expires_in: int = 3600,
|
|
1799
|
+
api_key: Optional[str] = None,
|
|
1800
|
+
api_key_header: str = "Authorization",
|
|
1801
|
+
allowed_tools: Optional[list] = None,
|
|
1802
|
+
blocked_tools: Optional[list] = None,
|
|
1803
|
+
) -> Dict[str, Any]:
|
|
1804
|
+
"""Add or update an MCP server for a user."""
|
|
1805
|
+
endpoint, params = self._prepare_add_user_mcp_server(
|
|
1806
|
+
user_id, server_name, endpoint_url, transport_type, auth_type,
|
|
1807
|
+
oauth_client_id, oauth_client_secret, access_token, refresh_token,
|
|
1808
|
+
token_expires_in, api_key, api_key_header, allowed_tools, blocked_tools,
|
|
1809
|
+
)
|
|
1810
|
+
return self._request_post_form(endpoint, params)
|
|
1811
|
+
|
|
1812
|
+
def get_user_mcp_servers(self, user_id: str) -> Dict[str, Any]:
|
|
1813
|
+
"""Get all MCP servers configured for a user."""
|
|
1814
|
+
endpoint, params = self._prepare_get_user_mcp_servers(user_id)
|
|
1815
|
+
try:
|
|
1816
|
+
return self._request_get(endpoint, params)
|
|
1817
|
+
except Exception as e:
|
|
1818
|
+
return {"user_id": user_id, "mcp_servers": [], "error": str(e)}
|
|
1819
|
+
|
|
1820
|
+
def update_mcp_server_tokens(
|
|
1821
|
+
self,
|
|
1822
|
+
user_id: str,
|
|
1823
|
+
server_name: str,
|
|
1824
|
+
access_token: str,
|
|
1825
|
+
refresh_token: Optional[str] = None,
|
|
1826
|
+
token_expires_in: int = 3600,
|
|
1827
|
+
) -> Dict[str, Any]:
|
|
1828
|
+
"""Update OAuth tokens for an MCP server."""
|
|
1829
|
+
endpoint, params = self._prepare_update_mcp_server_tokens(user_id, server_name, access_token, refresh_token, token_expires_in)
|
|
1830
|
+
return self._request_post_form(endpoint, params)
|
|
1831
|
+
|
|
1832
|
+
def remove_user_mcp_server(self, user_id: str, server_name: str) -> Dict[str, Any]:
|
|
1833
|
+
"""Remove an MCP server from a user."""
|
|
1834
|
+
endpoint, params = self._prepare_remove_user_mcp_server(user_id, server_name)
|
|
1835
|
+
return self._request_delete(endpoint, params)
|
|
1836
|
+
|
|
1837
|
+
def list_users(
|
|
1838
|
+
self,
|
|
1839
|
+
status: Optional[str] = None,
|
|
1840
|
+
limit: int = 50,
|
|
1841
|
+
offset: int = 0,
|
|
1842
|
+
include_mcp_count: bool = True,
|
|
1843
|
+
) -> Dict[str, Any]:
|
|
1844
|
+
"""
|
|
1845
|
+
List all users for this tenant with pagination and filtering.
|
|
1846
|
+
|
|
1847
|
+
Args:
|
|
1848
|
+
status: Filter by status (Active, Suspended, Revoked) - optional
|
|
1849
|
+
limit: Max results per page (default 50, max 100)
|
|
1850
|
+
offset: Pagination offset
|
|
1851
|
+
include_mcp_count: Include MCP server count per user (default True)
|
|
1852
|
+
|
|
1853
|
+
Returns:
|
|
1854
|
+
Dict with users list and pagination info
|
|
1855
|
+
"""
|
|
1856
|
+
endpoint, params = self._prepare_list_users(status, limit, offset, include_mcp_count)
|
|
1857
|
+
return self._request_get(endpoint, params)
|
|
1858
|
+
|
|
1859
|
+
def update_user(
|
|
1860
|
+
self,
|
|
1861
|
+
user_id: str,
|
|
1862
|
+
display_name: Optional[str] = None,
|
|
1863
|
+
custom_instructions: Optional[str] = None,
|
|
1864
|
+
locale: Optional[str] = None,
|
|
1865
|
+
timezone: Optional[str] = None,
|
|
1866
|
+
user_role: Optional[str] = None,
|
|
1867
|
+
email: Optional[str] = None,
|
|
1868
|
+
job_title: Optional[str] = None,
|
|
1869
|
+
department: Optional[str] = None,
|
|
1870
|
+
about: Optional[str] = None,
|
|
1871
|
+
) -> Dict[str, Any]:
|
|
1872
|
+
"""
|
|
1873
|
+
Update user profile fields.
|
|
1874
|
+
|
|
1875
|
+
Args:
|
|
1876
|
+
user_id: User identifier
|
|
1877
|
+
display_name: New display name (optional)
|
|
1878
|
+
custom_instructions: New custom instructions (optional)
|
|
1879
|
+
locale: User's locale/language preference (optional)
|
|
1880
|
+
timezone: User's timezone (optional)
|
|
1881
|
+
user_role: User's role (optional)
|
|
1882
|
+
email: User's email (optional)
|
|
1883
|
+
job_title: User's job title (optional)
|
|
1884
|
+
department: User's department or team (optional)
|
|
1885
|
+
about: Brief self-description, max 500 chars (optional)
|
|
1886
|
+
|
|
1887
|
+
Returns:
|
|
1888
|
+
Dict with success status and message
|
|
1889
|
+
"""
|
|
1890
|
+
endpoint, params = self._prepare_update_user(
|
|
1891
|
+
user_id, display_name, custom_instructions,
|
|
1892
|
+
locale=locale, timezone=timezone, user_role=user_role, email=email,
|
|
1893
|
+
job_title=job_title, department=department, about=about,
|
|
1894
|
+
)
|
|
1895
|
+
return self._request_post_form(endpoint, params)
|
|
1896
|
+
|
|
1897
|
+
def deregister_user(self, user_id: str) -> Dict[str, Any]:
|
|
1898
|
+
"""Permanently delete a user and all their MCP servers."""
|
|
1899
|
+
endpoint, params = self._prepare_deregister_user(user_id)
|
|
1900
|
+
return self._request_post_form(endpoint, params)
|
|
1901
|
+
|
|
1902
|
+
def get_user_limit_status(self) -> Dict[str, Any]:
|
|
1903
|
+
"""Get user count and limit for this tenant."""
|
|
1904
|
+
endpoint, params = self._prepare_get_user_limit_status()
|
|
1905
|
+
return self._request_get(endpoint, params)
|
|
1906
|
+
|
|
1907
|
+
def suspend_user(self, user_id: str) -> Dict[str, Any]:
|
|
1908
|
+
"""Suspend a user (disable their access)."""
|
|
1909
|
+
endpoint, params = self._prepare_suspend_user(user_id)
|
|
1910
|
+
return self._request_post_form(endpoint, params)
|
|
1911
|
+
|
|
1912
|
+
def revoke_user(self, user_id: str) -> Dict[str, Any]:
|
|
1913
|
+
"""Revoke a user (permanently disable, also disables their MCP servers)."""
|
|
1914
|
+
endpoint, params = self._prepare_revoke_user(user_id)
|
|
1915
|
+
return self._request_post_form(endpoint, params)
|
|
1916
|
+
|
|
1917
|
+
def set_user_credit_limit(
|
|
1918
|
+
self,
|
|
1919
|
+
user_id: str,
|
|
1920
|
+
monthly_credit_limit: float = 0,
|
|
1921
|
+
acted_by: Optional[str] = None,
|
|
1922
|
+
) -> Dict[str, Any]:
|
|
1923
|
+
"""Set a per-user monthly credit limit. 0 = no limit (shared pool).
|
|
1924
|
+
|
|
1925
|
+
``acted_by`` is the acting admin's user_id (recorded on the member
|
|
1926
|
+
audit log when provided).
|
|
1927
|
+
"""
|
|
1928
|
+
endpoint = "users.set_user_credit_limit"
|
|
1929
|
+
params = {
|
|
1930
|
+
"tenant_id": self.tenant_id,
|
|
1931
|
+
"user_id": user_id,
|
|
1932
|
+
"monthly_credit_limit": str(monthly_credit_limit),
|
|
1933
|
+
}
|
|
1934
|
+
if acted_by:
|
|
1935
|
+
params["acted_by"] = acted_by
|
|
1936
|
+
return self._request_post_form(endpoint, params)
|
|
1937
|
+
|
|
1938
|
+
def get_my_credit_status(self, user_id: str) -> Dict[str, Any]:
|
|
1939
|
+
"""Get credit usage status for a specific user."""
|
|
1940
|
+
endpoint = "users.get_my_credit_status"
|
|
1941
|
+
params = {
|
|
1942
|
+
"tenant_id": self.tenant_id,
|
|
1943
|
+
"user_id": user_id,
|
|
1944
|
+
}
|
|
1945
|
+
return self._request_get(endpoint, params)
|
|
1946
|
+
|
|
1947
|
+
# =========================================================================
|
|
1948
|
+
# Workflow APIs
|
|
1949
|
+
# =========================================================================
|
|
1950
|
+
# These methods route through workflows_api_base -> assistant_runtime_workflows.api
|
|
1951
|
+
|
|
1952
|
+
def create_workflow(
|
|
1953
|
+
self,
|
|
1954
|
+
workflow_name: str,
|
|
1955
|
+
graph_json: Optional[str] = None,
|
|
1956
|
+
description: str = "",
|
|
1957
|
+
default_model_id: Optional[str] = None,
|
|
1958
|
+
default_user_id: Optional[str] = None,
|
|
1959
|
+
error_strategy: str = "fail_fast",
|
|
1960
|
+
timeout_seconds: int = 600,
|
|
1961
|
+
) -> Optional[Dict[str, Any]]:
|
|
1962
|
+
"""
|
|
1963
|
+
Create a new workflow.
|
|
1964
|
+
|
|
1965
|
+
Args:
|
|
1966
|
+
workflow_name: Human-readable workflow name (must be unique per tenant)
|
|
1967
|
+
graph_json: Workflow graph JSON string (optional, can be set later)
|
|
1968
|
+
description: Optional description
|
|
1969
|
+
default_model_id: Default LLM model for agent nodes
|
|
1970
|
+
default_user_id: Default user whose MCP tools are used
|
|
1971
|
+
error_strategy: "fail_fast", "continue", or "retry" (default: fail_fast)
|
|
1972
|
+
timeout_seconds: Max execution time in seconds (default: 600)
|
|
1973
|
+
|
|
1974
|
+
Returns:
|
|
1975
|
+
{"name": str, "workflow_name": str, "status": str, "version": int}
|
|
1976
|
+
"""
|
|
1977
|
+
endpoint, payload = self._prepare_create_workflow(
|
|
1978
|
+
workflow_name, graph_json, description, default_model_id,
|
|
1979
|
+
default_user_id, error_strategy, timeout_seconds,
|
|
1980
|
+
)
|
|
1981
|
+
return self._request_post_json(endpoint, payload, api_base=self.workflows_api_base)
|
|
1982
|
+
|
|
1983
|
+
def get_workflow(
|
|
1984
|
+
self,
|
|
1985
|
+
name: Optional[str] = None,
|
|
1986
|
+
workflow_name: Optional[str] = None,
|
|
1987
|
+
) -> Optional[Dict[str, Any]]:
|
|
1988
|
+
"""Get a workflow definition by document name or human-readable name."""
|
|
1989
|
+
endpoint, params = self._prepare_get_workflow(name, workflow_name)
|
|
1990
|
+
return self._request_get(endpoint, params, api_base=self.workflows_api_base)
|
|
1991
|
+
|
|
1992
|
+
def update_workflow(
|
|
1993
|
+
self,
|
|
1994
|
+
name: str,
|
|
1995
|
+
graph_json: Optional[str] = None,
|
|
1996
|
+
workflow_name: Optional[str] = None,
|
|
1997
|
+
description: Optional[str] = None,
|
|
1998
|
+
status: Optional[str] = None,
|
|
1999
|
+
default_model_id: Optional[str] = None,
|
|
2000
|
+
default_user_id: Optional[str] = None,
|
|
2001
|
+
error_strategy: Optional[str] = None,
|
|
2002
|
+
timeout_seconds: Optional[int] = None,
|
|
2003
|
+
max_node_executions: Optional[int] = None,
|
|
2004
|
+
max_retries: Optional[int] = None,
|
|
2005
|
+
) -> Optional[Dict[str, Any]]:
|
|
2006
|
+
"""
|
|
2007
|
+
Update a workflow. Increments version when graph_json changes.
|
|
2008
|
+
|
|
2009
|
+
Args:
|
|
2010
|
+
name: Document name (e.g., "WF-00001")
|
|
2011
|
+
graph_json: Updated workflow graph
|
|
2012
|
+
workflow_name: New human-readable name
|
|
2013
|
+
description: New description
|
|
2014
|
+
status: New status ("Draft", "Active", "Paused", "Archived")
|
|
2015
|
+
default_model_id: New default model
|
|
2016
|
+
default_user_id: New default user
|
|
2017
|
+
error_strategy: New error strategy
|
|
2018
|
+
timeout_seconds: New timeout
|
|
2019
|
+
max_node_executions: New max executions
|
|
2020
|
+
max_retries: New max retries
|
|
2021
|
+
|
|
2022
|
+
Returns:
|
|
2023
|
+
{"name": str, "workflow_name": str, "status": str, "version": int}
|
|
2024
|
+
"""
|
|
2025
|
+
endpoint, payload = self._prepare_update_workflow(
|
|
2026
|
+
name, graph_json, workflow_name, description, status,
|
|
2027
|
+
default_model_id, default_user_id, error_strategy,
|
|
2028
|
+
timeout_seconds, max_node_executions, max_retries,
|
|
2029
|
+
)
|
|
2030
|
+
return self._request_post_json(endpoint, payload, api_base=self.workflows_api_base)
|
|
2031
|
+
|
|
2032
|
+
def delete_workflow(self, name: str) -> Optional[Dict[str, Any]]:
|
|
2033
|
+
"""Soft-delete a workflow (sets status to Archived)."""
|
|
2034
|
+
endpoint, payload = self._prepare_delete_workflow(name)
|
|
2035
|
+
return self._request_post_json(endpoint, payload, api_base=self.workflows_api_base)
|
|
2036
|
+
|
|
2037
|
+
def list_workflows(
|
|
2038
|
+
self,
|
|
2039
|
+
status: Optional[str] = None,
|
|
2040
|
+
page: int = 0,
|
|
2041
|
+
page_size: int = 20,
|
|
2042
|
+
) -> Optional[Dict[str, Any]]:
|
|
2043
|
+
"""
|
|
2044
|
+
List workflows for this tenant.
|
|
2045
|
+
|
|
2046
|
+
Args:
|
|
2047
|
+
status: Filter by status (optional, excludes Archived by default)
|
|
2048
|
+
page: Page number, 0-indexed (default: 0)
|
|
2049
|
+
page_size: Items per page, max 100 (default: 20)
|
|
2050
|
+
|
|
2051
|
+
Returns:
|
|
2052
|
+
{"workflows": [...], "total": int, "page": int, "page_size": int}
|
|
2053
|
+
"""
|
|
2054
|
+
endpoint, params = self._prepare_list_workflows(status, page, page_size)
|
|
2055
|
+
return self._request_get(endpoint, params, api_base=self.workflows_api_base)
|
|
2056
|
+
|
|
2057
|
+
def execute_workflow(
|
|
2058
|
+
self,
|
|
2059
|
+
name: str,
|
|
2060
|
+
input_data: Optional[str] = None,
|
|
2061
|
+
user_id: Optional[str] = None,
|
|
2062
|
+
) -> Optional[Dict[str, Any]]:
|
|
2063
|
+
"""
|
|
2064
|
+
Manually trigger workflow execution.
|
|
2065
|
+
|
|
2066
|
+
Args:
|
|
2067
|
+
name: AR Workflow document name (e.g., "WF-00001")
|
|
2068
|
+
input_data: Input data (JSON string or plain text)
|
|
2069
|
+
user_id: User triggering the execution
|
|
2070
|
+
|
|
2071
|
+
Returns:
|
|
2072
|
+
{"status": "queued", "workflow": str, "workflow_name": str, "message": str}
|
|
2073
|
+
"""
|
|
2074
|
+
endpoint, payload = self._prepare_execute_workflow(name, input_data, user_id)
|
|
2075
|
+
return self._request_post_json(endpoint, payload, api_base=self.workflows_api_base)
|
|
2076
|
+
|
|
2077
|
+
def execute_workflow_from_event(
|
|
2078
|
+
self,
|
|
2079
|
+
workflow_name: str,
|
|
2080
|
+
input_data: Dict[str, Any],
|
|
2081
|
+
user_id: str,
|
|
2082
|
+
trigger_id: str,
|
|
2083
|
+
) -> Optional[Dict[str, Any]]:
|
|
2084
|
+
"""
|
|
2085
|
+
Trigger workflow execution from a FACO-side doc event.
|
|
2086
|
+
|
|
2087
|
+
Args:
|
|
2088
|
+
workflow_name: AR Workflow.workflow_name (human identifier)
|
|
2089
|
+
input_data: Structured payload {trigger, doc, changed_fields}
|
|
2090
|
+
user_id: User on the customer bench who saved the doc
|
|
2091
|
+
trigger_id: FACO AR Workflow Trigger name (for traceability)
|
|
2092
|
+
|
|
2093
|
+
Returns:
|
|
2094
|
+
{"status": "queued", "run_name": str, "run_id": str, ...}
|
|
2095
|
+
"""
|
|
2096
|
+
endpoint, payload = self._prepare_execute_workflow_from_event(
|
|
2097
|
+
workflow_name, input_data, user_id, trigger_id,
|
|
2098
|
+
)
|
|
2099
|
+
return self._request_post_json(endpoint, payload, api_base=self.workflows_api_base)
|
|
2100
|
+
|
|
2101
|
+
def cancel_workflow_run(self, run_name: str) -> Optional[Dict[str, Any]]:
|
|
2102
|
+
"""Cancel a queued or running workflow execution."""
|
|
2103
|
+
endpoint, payload = self._prepare_cancel_workflow_run(run_name)
|
|
2104
|
+
return self._request_post_json(endpoint, payload, api_base=self.workflows_api_base)
|
|
2105
|
+
|
|
2106
|
+
def get_workflow_run(self, run_name: str) -> Optional[Dict[str, Any]]:
|
|
2107
|
+
"""Get execution run details including per-node results."""
|
|
2108
|
+
endpoint, params = self._prepare_get_workflow_run(run_name)
|
|
2109
|
+
return self._request_get(endpoint, params, api_base=self.workflows_api_base)
|
|
2110
|
+
|
|
2111
|
+
def list_workflow_runs(
|
|
2112
|
+
self,
|
|
2113
|
+
workflow_name: Optional[str] = None,
|
|
2114
|
+
status: Optional[str] = None,
|
|
2115
|
+
page: int = 0,
|
|
2116
|
+
page_size: int = 20,
|
|
2117
|
+
) -> Optional[Dict[str, Any]]:
|
|
2118
|
+
"""
|
|
2119
|
+
List workflow execution runs.
|
|
2120
|
+
|
|
2121
|
+
Args:
|
|
2122
|
+
workflow_name: Filter by workflow document name (optional)
|
|
2123
|
+
status: Filter by status (optional)
|
|
2124
|
+
page: Page number, 0-indexed (default: 0)
|
|
2125
|
+
page_size: Items per page, max 100 (default: 20)
|
|
2126
|
+
|
|
2127
|
+
Returns:
|
|
2128
|
+
{"runs": [...], "total": int, "page": int, "page_size": int}
|
|
2129
|
+
"""
|
|
2130
|
+
endpoint, params = self._prepare_list_workflow_runs(workflow_name, status, page, page_size)
|
|
2131
|
+
return self._request_get(endpoint, params, api_base=self.workflows_api_base)
|
|
2132
|
+
|
|
2133
|
+
def get_workflow_audit_summary(
|
|
2134
|
+
self,
|
|
2135
|
+
workflow_id: str,
|
|
2136
|
+
window: str = "last_7_days",
|
|
2137
|
+
) -> Optional[Dict[str, Any]]:
|
|
2138
|
+
"""
|
|
2139
|
+
Get an audit summary for a workflow over the given window.
|
|
2140
|
+
|
|
2141
|
+
Args:
|
|
2142
|
+
workflow_id: AR Workflow document name (e.g., "WF-00042")
|
|
2143
|
+
window: One of "this_week", "last_7_days" (default), "last_30_days"
|
|
2144
|
+
|
|
2145
|
+
Returns:
|
|
2146
|
+
{
|
|
2147
|
+
"workflow_id": str,
|
|
2148
|
+
"workflow_name": str,
|
|
2149
|
+
"window": str,
|
|
2150
|
+
"stats": {
|
|
2151
|
+
"runs": int,
|
|
2152
|
+
"success_rate": float (0..1),
|
|
2153
|
+
"avg_latency_seconds": float,
|
|
2154
|
+
"avg_credits_used": float,
|
|
2155
|
+
"trigger_breakdown": {"manual": int, "scheduled": int, "api": int, "doc_event": int}
|
|
2156
|
+
},
|
|
2157
|
+
"runs_per_day": [{"date": "YYYY-MM-DD", "completed": int, "failed": int, "running": int}, ...]
|
|
2158
|
+
}
|
|
2159
|
+
"""
|
|
2160
|
+
endpoint, params = self._prepare_get_workflow_audit_summary(workflow_id, window)
|
|
2161
|
+
return self._request_get(endpoint, params, api_base=self.workflows_api_base)
|
|
2162
|
+
|
|
2163
|
+
def set_workflow_schedule(
|
|
2164
|
+
self,
|
|
2165
|
+
name: str,
|
|
2166
|
+
cron_expression: str,
|
|
2167
|
+
timezone: str = "UTC",
|
|
2168
|
+
enabled: bool = True,
|
|
2169
|
+
default_input: Optional[str] = None,
|
|
2170
|
+
) -> Optional[Dict[str, Any]]:
|
|
2171
|
+
"""
|
|
2172
|
+
Set or update cron schedule for a workflow.
|
|
2173
|
+
|
|
2174
|
+
Args:
|
|
2175
|
+
name: AR Workflow document name
|
|
2176
|
+
cron_expression: Standard cron expression (e.g., "0 9 * * 1-5")
|
|
2177
|
+
timezone: IANA timezone (default: "UTC")
|
|
2178
|
+
enabled: Whether schedule is active (default: True)
|
|
2179
|
+
default_input: Input data for scheduled runs (JSON string)
|
|
2180
|
+
|
|
2181
|
+
Returns:
|
|
2182
|
+
{"name": str, "schedule_enabled": bool, "cron_expression": str,
|
|
2183
|
+
"timezone": str, "next_run_at": str}
|
|
2184
|
+
"""
|
|
2185
|
+
endpoint, payload = self._prepare_set_workflow_schedule(name, cron_expression, timezone, enabled, default_input)
|
|
2186
|
+
return self._request_post_json(endpoint, payload, api_base=self.workflows_api_base)
|
|
2187
|
+
|
|
2188
|
+
def validate_workflow_graph(self, graph_json: str) -> Optional[Dict[str, Any]]:
|
|
2189
|
+
"""
|
|
2190
|
+
Validate a workflow graph JSON without saving.
|
|
2191
|
+
|
|
2192
|
+
Args:
|
|
2193
|
+
graph_json: Graph JSON string to validate
|
|
2194
|
+
|
|
2195
|
+
Returns:
|
|
2196
|
+
If valid: {"valid": True, "stats": {...}}
|
|
2197
|
+
If invalid: {"valid": False, "error": str}
|
|
2198
|
+
"""
|
|
2199
|
+
endpoint, payload = self._prepare_validate_workflow_graph(graph_json)
|
|
2200
|
+
return self._request_post_json(endpoint, payload, api_base=self.workflows_api_base)
|
|
2201
|
+
|
|
2202
|
+
def test_workflow_node(
|
|
2203
|
+
self,
|
|
2204
|
+
node_json: str,
|
|
2205
|
+
input_text: str = "Test input",
|
|
2206
|
+
default_model_id: Optional[str] = None,
|
|
2207
|
+
default_user_id: Optional[str] = None,
|
|
2208
|
+
) -> Optional[Dict[str, Any]]:
|
|
2209
|
+
"""
|
|
2210
|
+
Execute a single node in isolation for testing.
|
|
2211
|
+
|
|
2212
|
+
Args:
|
|
2213
|
+
node_json: Single node definition as JSON string
|
|
2214
|
+
input_text: Test input text (default: "Test input")
|
|
2215
|
+
default_model_id: LLM model to use for agent nodes
|
|
2216
|
+
default_user_id: User whose MCP tools to use
|
|
2217
|
+
|
|
2218
|
+
Returns:
|
|
2219
|
+
{"status": str, "node_result": {...}, "duration_ms": int, "tokens_used": int}
|
|
2220
|
+
"""
|
|
2221
|
+
endpoint, payload = self._prepare_test_workflow_node(node_json, input_text, default_model_id, default_user_id)
|
|
2222
|
+
return self._request_post_json(endpoint, payload, timeout=120.0, api_base=self.workflows_api_base)
|
|
2223
|
+
|
|
2224
|
+
def resolve_workflow_tools(
|
|
2225
|
+
self,
|
|
2226
|
+
user_id: str,
|
|
2227
|
+
tool_directives: List[Dict[str, Any]],
|
|
2228
|
+
) -> Optional[Dict[str, Any]]:
|
|
2229
|
+
"""
|
|
2230
|
+
Preview how tool directives resolve against a user's MCP tools.
|
|
2231
|
+
|
|
2232
|
+
Args:
|
|
2233
|
+
user_id: User whose MCP tools to check against
|
|
2234
|
+
tool_directives: List of directive dicts
|
|
2235
|
+
|
|
2236
|
+
Returns:
|
|
2237
|
+
{"resolved": [...], "all_tools_available": bool, "missing_tools": [...]}
|
|
2238
|
+
"""
|
|
2239
|
+
endpoint, payload = self._prepare_resolve_workflow_tools(user_id, tool_directives)
|
|
2240
|
+
return self._request_post_json(endpoint, payload, api_base=self.workflows_api_base)
|
|
2241
|
+
|
|
2242
|
+
def run_workflow_node(
|
|
2243
|
+
self,
|
|
2244
|
+
name: str,
|
|
2245
|
+
node_id: str,
|
|
2246
|
+
input_text: str = "Test input",
|
|
2247
|
+
user_id: Optional[str] = None,
|
|
2248
|
+
) -> Optional[Dict[str, Any]]:
|
|
2249
|
+
"""
|
|
2250
|
+
Run a single node from a saved workflow with manual input.
|
|
2251
|
+
|
|
2252
|
+
Args:
|
|
2253
|
+
name: Workflow document name or workflow_name
|
|
2254
|
+
node_id: Node ID within the graph
|
|
2255
|
+
input_text: Input text to feed the node
|
|
2256
|
+
user_id: Override the workflow's default_user_id
|
|
2257
|
+
|
|
2258
|
+
Returns:
|
|
2259
|
+
{"status": str, "node_id": str, "node_result": {...}, "duration_ms": int, "tokens_used": int}
|
|
2260
|
+
"""
|
|
2261
|
+
endpoint, payload = self._prepare_run_workflow_node(name, node_id, input_text, user_id)
|
|
2262
|
+
return self._request_post_json(endpoint, payload, api_base=self.workflows_api_base, timeout=120.0)
|
|
2263
|
+
|
|
2264
|
+
# --- Workflow Templates ---
|
|
2265
|
+
|
|
2266
|
+
def export_workflow(
|
|
2267
|
+
self,
|
|
2268
|
+
name: str,
|
|
2269
|
+
template_name: Optional[str] = None,
|
|
2270
|
+
category: str = "General",
|
|
2271
|
+
save_as_template: bool = False,
|
|
2272
|
+
is_public: bool = False,
|
|
2273
|
+
) -> Optional[Dict[str, Any]]:
|
|
2274
|
+
"""Export a workflow as a portable template JSON."""
|
|
2275
|
+
endpoint, payload = self._prepare_export_workflow(name, template_name, category, save_as_template, is_public)
|
|
2276
|
+
return self._request_post_json(endpoint, payload, api_base=self.workflows_api_base)
|
|
2277
|
+
|
|
2278
|
+
# `list_templates`, `get_template`, `import_template`, `update_template`,
|
|
2279
|
+
# and `delete_template` removed in chunk 4 — use the marketplace methods
|
|
2280
|
+
# (`list_listings(listing_type="Workflow")`, `get_listing`, `import_listing`,
|
|
2281
|
+
# `update_listing`, `delete_listing`) instead.
|
|
2282
|
+
|
|
2283
|
+
def upload_listing_from_json(
|
|
2284
|
+
self,
|
|
2285
|
+
file_path: str,
|
|
2286
|
+
user_id: str,
|
|
2287
|
+
is_public: bool = False,
|
|
2288
|
+
is_published: bool = True,
|
|
2289
|
+
plan_tier: Optional[str] = None,
|
|
2290
|
+
timeout: Optional[float] = None,
|
|
2291
|
+
) -> Optional[Dict[str, Any]]:
|
|
2292
|
+
"""Upload an ``ar_workflow_template_v1`` JSON file + create a listing.
|
|
2293
|
+
|
|
2294
|
+
Creates an AR Workflow Template owning the graph definition, then
|
|
2295
|
+
wraps it in an AR Marketplace Listing. Replaces the legacy
|
|
2296
|
+
``upload_template`` method (deleted in chunk 5).
|
|
2297
|
+
"""
|
|
2298
|
+
payload: Dict[str, Any] = {
|
|
2299
|
+
"tenant_id": self.tenant_id,
|
|
2300
|
+
"user_id": user_id,
|
|
2301
|
+
"is_public": "1" if is_public else "0",
|
|
2302
|
+
"is_published": "1" if is_published else "0",
|
|
2303
|
+
}
|
|
2304
|
+
if plan_tier:
|
|
2305
|
+
payload["plan_tier"] = plan_tier
|
|
2306
|
+
url = f"{self.marketplace_api_base}.publishing.upload_listing_from_json"
|
|
2307
|
+
payload = self._with_site_url(payload)
|
|
2308
|
+
headers = self._get_headers(payload, for_query_string=False)
|
|
2309
|
+
timeout = timeout or self.timeout
|
|
2310
|
+
|
|
2311
|
+
try:
|
|
2312
|
+
with open(file_path, "rb") as f:
|
|
2313
|
+
response = requests.post(
|
|
2314
|
+
url,
|
|
2315
|
+
data=payload,
|
|
2316
|
+
files={"file": (os.path.basename(file_path), f, "application/json")},
|
|
2317
|
+
headers=headers,
|
|
2318
|
+
timeout=timeout,
|
|
2319
|
+
)
|
|
2320
|
+
response.raise_for_status()
|
|
2321
|
+
return response.json().get("message", response.json())
|
|
2322
|
+
except requests.exceptions.HTTPError as e:
|
|
2323
|
+
status_code = e.response.status_code if e.response is not None else None
|
|
2324
|
+
msg, body = (
|
|
2325
|
+
self._extract_error_payload(e.response) if e.response is not None else (None, None)
|
|
2326
|
+
)
|
|
2327
|
+
raise ARAPIError(msg or str(e), status_code=status_code, response_data=body) from e
|
|
2328
|
+
except requests.exceptions.RequestException as e:
|
|
2329
|
+
raise ARAPIError(str(e)) from e
|
|
2330
|
+
|
|
2331
|
+
# `rate_template` removed in chunk 4 — use `rate_listing` instead.
|
|
2332
|
+
# `download_template` removed in chunk 5 — use `download_listing_as_json` instead.
|
|
2333
|
+
|
|
2334
|
+
# --- GDPR / Privacy ---
|
|
2335
|
+
|
|
2336
|
+
def export_user_data(self, user_id: str) -> Optional[Dict[str, Any]]:
|
|
2337
|
+
"""Export all user data as structured JSON (GDPR Article 20)."""
|
|
2338
|
+
endpoint, payload = self._prepare_export_user_data(user_id)
|
|
2339
|
+
return self._request_post_json(endpoint, payload)
|
|
2340
|
+
|
|
2341
|
+
def erase_user_data(self, user_id: str) -> Optional[Dict[str, Any]]:
|
|
2342
|
+
"""Permanently delete all user data (GDPR Article 17)."""
|
|
2343
|
+
endpoint, payload = self._prepare_erase_user_data(user_id)
|
|
2344
|
+
return self._request_post_json(endpoint, payload)
|
|
2345
|
+
|
|
2346
|
+
def rectify_user_data(self, user_id: str, updates: dict) -> Optional[Dict[str, Any]]:
|
|
2347
|
+
"""Update user's personal data (GDPR Article 16)."""
|
|
2348
|
+
endpoint, payload = self._prepare_rectify_user_data(user_id, updates)
|
|
2349
|
+
return self._request_post_json(endpoint, payload)
|
|
2350
|
+
|
|
2351
|
+
def restrict_user_processing(self, user_id: str, restrict: bool = True) -> Optional[Dict[str, Any]]:
|
|
2352
|
+
"""Restrict or unrestrict data processing for a user (GDPR Article 18)."""
|
|
2353
|
+
endpoint, payload = self._prepare_restrict_user_processing(user_id, restrict)
|
|
2354
|
+
return self._request_post_json(endpoint, payload)
|
|
2355
|
+
|
|
2356
|
+
def update_user_consent(self, user_id: str, consent_type: str, granted: bool = True) -> Optional[Dict[str, Any]]:
|
|
2357
|
+
"""Update user consent for a specific processing activity."""
|
|
2358
|
+
endpoint, payload = self._prepare_update_user_consent(user_id, consent_type, granted)
|
|
2359
|
+
return self._request_post_json(endpoint, payload)
|
|
2360
|
+
|
|
2361
|
+
def create_ticket(self, user_id: str, subject: str, description: str,
|
|
2362
|
+
category: Optional[str] = None,
|
|
2363
|
+
conversation_id: Optional[str] = None,
|
|
2364
|
+
environment: Optional[dict] = None,
|
|
2365
|
+
attachment_ids: Optional[List[str]] = None) -> Optional[Dict[str, Any]]:
|
|
2366
|
+
"""Raise a support ticket. Returns {ticket_id, portal_link}."""
|
|
2367
|
+
endpoint, payload = self._prepare_create_ticket(
|
|
2368
|
+
user_id, subject, description, category, conversation_id, environment,
|
|
2369
|
+
attachment_ids)
|
|
2370
|
+
return self._request_post_json(endpoint, payload)
|
|
2371
|
+
|
|
2372
|
+
def submit_feedback(self, user_id: str, rating: Optional[int] = None,
|
|
2373
|
+
comment: Optional[str] = None,
|
|
2374
|
+
category: Optional[str] = None,
|
|
2375
|
+
conversation_id: Optional[str] = None,
|
|
2376
|
+
environment: Optional[dict] = None) -> Optional[Dict[str, Any]]:
|
|
2377
|
+
"""Submit product/service feedback. Returns {feedback_id}."""
|
|
2378
|
+
endpoint, payload = self._prepare_submit_feedback(
|
|
2379
|
+
user_id, rating, comment, category, conversation_id, environment)
|
|
2380
|
+
return self._request_post_json(endpoint, payload)
|
|
2381
|
+
|
|
2382
|
+
def list_tickets(self, user_id: str, status: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
|
2383
|
+
"""List the user's support tickets. Returns {tickets: [...]}."""
|
|
2384
|
+
endpoint, payload = self._prepare_list_tickets(user_id, status)
|
|
2385
|
+
return self._request_post_json(endpoint, payload)
|
|
2386
|
+
|
|
2387
|
+
def get_ticket_thread(self, user_id: str, ticket_id: str) -> Optional[Dict[str, Any]]:
|
|
2388
|
+
"""Get a ticket's full conversation thread. Returns {ticket, messages: [...]}."""
|
|
2389
|
+
endpoint, payload = self._prepare_get_ticket_thread(user_id, ticket_id)
|
|
2390
|
+
return self._request_post_json(endpoint, payload)
|
|
2391
|
+
|
|
2392
|
+
def reply_to_ticket(self, user_id: str, ticket_id: str, message: str,
|
|
2393
|
+
attachment_ids: Optional[List[str]] = None) -> Optional[Dict[str, Any]]:
|
|
2394
|
+
"""Post a reply to a ticket. Returns {message_id}."""
|
|
2395
|
+
endpoint, payload = self._prepare_reply_to_ticket(user_id, ticket_id, message, attachment_ids)
|
|
2396
|
+
return self._request_post_json(endpoint, payload)
|
|
2397
|
+
|
|
2398
|
+
def upload_ticket_attachment(
|
|
2399
|
+
self,
|
|
2400
|
+
user_id: str,
|
|
2401
|
+
file_name: str,
|
|
2402
|
+
file_data: bytes,
|
|
2403
|
+
content_type: Optional[str] = None,
|
|
2404
|
+
) -> Optional[Dict[str, Any]]:
|
|
2405
|
+
"""Pre-upload a support-ticket attachment (image or PDF).
|
|
2406
|
+
|
|
2407
|
+
Stores a private, pending File on AR scoped to this tenant+user and
|
|
2408
|
+
returns a reference to embed later via create_ticket/reply_to_ticket's
|
|
2409
|
+
``attachment_ids``.
|
|
2410
|
+
|
|
2411
|
+
Returns:
|
|
2412
|
+
{"file_id": str, "file_url": str, "file_name": str, "is_image": bool}
|
|
2413
|
+
"""
|
|
2414
|
+
endpoint, params, f_field, f_name, f_data, c_type = self._prepare_upload_ticket_attachment(
|
|
2415
|
+
user_id, file_name, file_data, content_type,
|
|
2416
|
+
)
|
|
2417
|
+
return self._request_post_multipart(
|
|
2418
|
+
endpoint, params=params, file_field=f_field,
|
|
2419
|
+
file_name=f_name, file_data=f_data, content_type=c_type,
|
|
2420
|
+
timeout=120.0,
|
|
2421
|
+
)
|
|
2422
|
+
|
|
2423
|
+
def get_tenant_privacy_config(self) -> Optional[Dict[str, Any]]:
|
|
2424
|
+
"""Get tenant's privacy policy configuration."""
|
|
2425
|
+
endpoint, payload = self._prepare_get_tenant_privacy_config()
|
|
2426
|
+
return self._request_post_json(endpoint, payload)
|
|
2427
|
+
|
|
2428
|
+
def update_tenant_privacy_config(self, config: dict) -> Optional[Dict[str, Any]]:
|
|
2429
|
+
"""Update tenant's privacy policy configuration."""
|
|
2430
|
+
endpoint, payload = self._prepare_update_tenant_privacy_config(config)
|
|
2431
|
+
return self._request_post_json(endpoint, payload)
|
|
2432
|
+
|
|
2433
|
+
# --- Heartbeat & Notifications ---
|
|
2434
|
+
|
|
2435
|
+
def heartbeat(
|
|
2436
|
+
self,
|
|
2437
|
+
faco_version: Optional[str] = None,
|
|
2438
|
+
fac_version: Optional[str] = None,
|
|
2439
|
+
frappe_version: Optional[str] = None,
|
|
2440
|
+
erpnext_version: Optional[str] = None,
|
|
2441
|
+
python_version: Optional[str] = None,
|
|
2442
|
+
copilot_version: Optional[str] = None,
|
|
2443
|
+
timeout: Optional[float] = None,
|
|
2444
|
+
) -> Optional[Dict[str, Any]]:
|
|
2445
|
+
"""Send version/health info and receive pending notifications."""
|
|
2446
|
+
endpoint, payload = self._prepare_heartbeat(
|
|
2447
|
+
faco_version, fac_version, frappe_version, erpnext_version,
|
|
2448
|
+
python_version, copilot_version,
|
|
2449
|
+
)
|
|
2450
|
+
return self._request_post_json(endpoint, payload, timeout=timeout)
|
|
2451
|
+
|
|
2452
|
+
def get_notifications(self, user_id: str, timeout: Optional[float] = None) -> Optional[Dict[str, Any]]:
|
|
2453
|
+
"""Fetch pending notifications for a user."""
|
|
2454
|
+
endpoint, payload = self._prepare_get_notifications(user_id)
|
|
2455
|
+
return self._request_post_json(endpoint, payload, timeout=timeout)
|
|
2456
|
+
|
|
2457
|
+
def dismiss_notification(
|
|
2458
|
+
self,
|
|
2459
|
+
notification_id: str,
|
|
2460
|
+
user_id: str,
|
|
2461
|
+
timeout: Optional[float] = None,
|
|
2462
|
+
) -> Optional[Dict[str, Any]]:
|
|
2463
|
+
"""Record that a user has dismissed a notification."""
|
|
2464
|
+
endpoint, payload = self._prepare_dismiss_notification(notification_id, user_id)
|
|
2465
|
+
return self._request_post_json(endpoint, payload, timeout=timeout)
|
|
2466
|
+
|
|
2467
|
+
# -------------------------------------------------------------------
|
|
2468
|
+
# Marketplace API — routes via marketplace_api_base
|
|
2469
|
+
# -------------------------------------------------------------------
|
|
2470
|
+
|
|
2471
|
+
def list_listings(
|
|
2472
|
+
self,
|
|
2473
|
+
listing_type: Optional[str] = None,
|
|
2474
|
+
category: Optional[str] = None,
|
|
2475
|
+
search: Optional[str] = None,
|
|
2476
|
+
featured_only: bool = False,
|
|
2477
|
+
min_rating: Optional[float] = None,
|
|
2478
|
+
plan_tier: Optional[str] = None,
|
|
2479
|
+
sort_by: Optional[str] = None,
|
|
2480
|
+
page: int = 0,
|
|
2481
|
+
page_size: int = 20,
|
|
2482
|
+
user_id: Optional[str] = None,
|
|
2483
|
+
timeout: Optional[float] = None,
|
|
2484
|
+
) -> Optional[Dict[str, Any]]:
|
|
2485
|
+
"""List marketplace listings (workflows / prompts / skills) visible to the tenant."""
|
|
2486
|
+
endpoint, params = self._prepare_list_listings(
|
|
2487
|
+
listing_type, category, search, featured_only, min_rating,
|
|
2488
|
+
plan_tier, sort_by, page, page_size, user_id,
|
|
2489
|
+
)
|
|
2490
|
+
return self._request_get(endpoint, params, api_base=self.marketplace_api_base, timeout=timeout)
|
|
2491
|
+
|
|
2492
|
+
def get_listing(
|
|
2493
|
+
self, name: str, user_id: Optional[str] = None,
|
|
2494
|
+
include_source: bool = True,
|
|
2495
|
+
) -> Optional[Dict[str, Any]]:
|
|
2496
|
+
"""Get full listing details + the wrapped source record."""
|
|
2497
|
+
endpoint, params = self._prepare_get_listing(name, user_id, include_source)
|
|
2498
|
+
return self._request_get(endpoint, params, api_base=self.marketplace_api_base)
|
|
2499
|
+
|
|
2500
|
+
def import_listing(
|
|
2501
|
+
self,
|
|
2502
|
+
user_id: str,
|
|
2503
|
+
name: str,
|
|
2504
|
+
new_title: Optional[str] = None,
|
|
2505
|
+
variables: Optional[str] = None,
|
|
2506
|
+
default_model_id: Optional[str] = None,
|
|
2507
|
+
) -> Optional[Dict[str, Any]]:
|
|
2508
|
+
"""Import a marketplace listing into the requesting tenant."""
|
|
2509
|
+
endpoint, payload = self._prepare_import_listing(
|
|
2510
|
+
user_id, name, new_title, variables, default_model_id,
|
|
2511
|
+
)
|
|
2512
|
+
return self._request_post_json(endpoint, payload, api_base=self.marketplace_api_base)
|
|
2513
|
+
|
|
2514
|
+
def update_listing(
|
|
2515
|
+
self,
|
|
2516
|
+
name: str,
|
|
2517
|
+
title: Optional[str] = None,
|
|
2518
|
+
short_description: Optional[str] = None,
|
|
2519
|
+
description: Optional[str] = None,
|
|
2520
|
+
category: Optional[str] = None,
|
|
2521
|
+
tags: Optional[str] = None,
|
|
2522
|
+
icon: Optional[str] = None,
|
|
2523
|
+
is_public: Optional[bool] = None,
|
|
2524
|
+
is_published: Optional[bool] = None,
|
|
2525
|
+
plan_tier: Optional[str] = None,
|
|
2526
|
+
) -> Optional[Dict[str, Any]]:
|
|
2527
|
+
"""Update a marketplace listing's metadata. Owner-only or admin."""
|
|
2528
|
+
endpoint, payload = self._prepare_update_listing(
|
|
2529
|
+
name, title, short_description, description, category, tags,
|
|
2530
|
+
icon, is_public, is_published, plan_tier,
|
|
2531
|
+
)
|
|
2532
|
+
return self._request_post_json(endpoint, payload, api_base=self.marketplace_api_base)
|
|
2533
|
+
|
|
2534
|
+
def delete_listing(self, name: str) -> Optional[Dict[str, Any]]:
|
|
2535
|
+
"""Delete a marketplace listing. Owner-only or admin."""
|
|
2536
|
+
endpoint, payload = self._prepare_delete_listing(name)
|
|
2537
|
+
return self._request_post_json(endpoint, payload, api_base=self.marketplace_api_base)
|
|
2538
|
+
|
|
2539
|
+
def rate_listing(
|
|
2540
|
+
self, user_id: str, listing: str, rating: int,
|
|
2541
|
+
review: Optional[str] = None,
|
|
2542
|
+
) -> Optional[Dict[str, Any]]:
|
|
2543
|
+
"""Submit (or update) a 1-5 star rating + optional review."""
|
|
2544
|
+
endpoint, payload = self._prepare_rate_listing(user_id, listing, rating, review)
|
|
2545
|
+
return self._request_post_json(endpoint, payload, api_base=self.marketplace_api_base)
|
|
2546
|
+
|
|
2547
|
+
def report_listing(
|
|
2548
|
+
self, user_id: str, listing: str, reason: str,
|
|
2549
|
+
details: Optional[str] = None,
|
|
2550
|
+
) -> Optional[Dict[str, Any]]:
|
|
2551
|
+
"""File an abuse / spam / copyright report against a listing."""
|
|
2552
|
+
endpoint, payload = self._prepare_report_listing(user_id, listing, reason, details)
|
|
2553
|
+
return self._request_post_json(endpoint, payload, api_base=self.marketplace_api_base)
|
|
2554
|
+
|
|
2555
|
+
def list_pending_reviews(
|
|
2556
|
+
self, page: int = 0, page_size: int = 20,
|
|
2557
|
+
) -> Optional[Dict[str, Any]]:
|
|
2558
|
+
"""Admin: list listings awaiting moderation (oldest-first)."""
|
|
2559
|
+
endpoint, params = self._prepare_list_pending_reviews(page, page_size)
|
|
2560
|
+
return self._request_get(endpoint, params, api_base=self.marketplace_api_base)
|
|
2561
|
+
|
|
2562
|
+
def approve_listing(
|
|
2563
|
+
self, listing: str, notes: Optional[str] = None,
|
|
2564
|
+
) -> Optional[Dict[str, Any]]:
|
|
2565
|
+
"""Admin: approve a listing for cross-tenant visibility."""
|
|
2566
|
+
endpoint, payload = self._prepare_approve_listing(listing, notes)
|
|
2567
|
+
return self._request_post_json(endpoint, payload, api_base=self.marketplace_api_base)
|
|
2568
|
+
|
|
2569
|
+
def reject_listing(
|
|
2570
|
+
self, listing: str, notes: Optional[str] = None,
|
|
2571
|
+
) -> Optional[Dict[str, Any]]:
|
|
2572
|
+
"""Admin: reject a listing's moderation request."""
|
|
2573
|
+
endpoint, payload = self._prepare_reject_listing(listing, notes)
|
|
2574
|
+
return self._request_post_json(endpoint, payload, api_base=self.marketplace_api_base)
|
|
2575
|
+
|
|
2576
|
+
def get_creator_stats(
|
|
2577
|
+
self, user_id: Optional[str] = None,
|
|
2578
|
+
) -> Optional[Dict[str, Any]]:
|
|
2579
|
+
"""Aggregate stats for listings authored by this tenant."""
|
|
2580
|
+
endpoint, params = self._prepare_get_creator_stats(user_id)
|
|
2581
|
+
return self._request_get(endpoint, params, api_base=self.marketplace_api_base)
|
|
2582
|
+
|
|
2583
|
+
def list_my_listings(
|
|
2584
|
+
self,
|
|
2585
|
+
user_id: Optional[str] = None,
|
|
2586
|
+
listing_type: Optional[str] = None,
|
|
2587
|
+
page: int = 0,
|
|
2588
|
+
page_size: int = 20,
|
|
2589
|
+
) -> Optional[Dict[str, Any]]:
|
|
2590
|
+
"""List listings created by this tenant."""
|
|
2591
|
+
endpoint, params = self._prepare_list_my_listings(user_id, listing_type, page, page_size)
|
|
2592
|
+
return self._request_get(endpoint, params, api_base=self.marketplace_api_base)
|
|
2593
|
+
|
|
2594
|
+
# -------------------------------------------------------------------
|
|
2595
|
+
# Marketplace publishing + downloads + version checks (chunk 5)
|
|
2596
|
+
# -------------------------------------------------------------------
|
|
2597
|
+
|
|
2598
|
+
def publish_workflow(
|
|
2599
|
+
self,
|
|
2600
|
+
user_id: str,
|
|
2601
|
+
workflow_name: str,
|
|
2602
|
+
template_name: Optional[str] = None,
|
|
2603
|
+
category: str = "General",
|
|
2604
|
+
short_description: Optional[str] = None,
|
|
2605
|
+
description: Optional[str] = None,
|
|
2606
|
+
tags: Optional[str] = None,
|
|
2607
|
+
is_public: bool = False,
|
|
2608
|
+
plan_tier: Optional[str] = None,
|
|
2609
|
+
) -> Optional[Dict[str, Any]]:
|
|
2610
|
+
"""Export a workflow + create a listing wrapping the new template."""
|
|
2611
|
+
endpoint, payload = self._prepare_publish_workflow(
|
|
2612
|
+
user_id, workflow_name, template_name, category, short_description,
|
|
2613
|
+
description, tags, is_public, plan_tier,
|
|
2614
|
+
)
|
|
2615
|
+
return self._request_post_json(endpoint, payload, api_base=self.marketplace_api_base)
|
|
2616
|
+
|
|
2617
|
+
def download_listing_as_json(self, name: str) -> Optional[Dict[str, Any]]:
|
|
2618
|
+
"""Return a workflow listing as portable ar_workflow_template_v1 JSON."""
|
|
2619
|
+
endpoint, params = self._prepare_download_listing_as_json(name)
|
|
2620
|
+
return self._request_get(endpoint, params, api_base=self.marketplace_api_base)
|
|
2621
|
+
|
|
2622
|
+
def check_workflow_update(self, name: str) -> Optional[Dict[str, Any]]:
|
|
2623
|
+
"""Check whether the workflow's source template has a newer version."""
|
|
2624
|
+
endpoint, params = self._prepare_check_workflow_update(name)
|
|
2625
|
+
return self._request_get(endpoint, params, api_base=self.marketplace_api_base)
|
|
2626
|
+
|
|
2627
|
+
def check_all_workflow_updates(self) -> Optional[Dict[str, Any]]:
|
|
2628
|
+
"""Batch-check all tenant workflows for available template updates."""
|
|
2629
|
+
endpoint, params = self._prepare_check_all_workflow_updates()
|
|
2630
|
+
return self._request_get(endpoint, params, api_base=self.marketplace_api_base)
|
|
2631
|
+
|
|
2632
|
+
# -------------------------------------------------------------------
|
|
2633
|
+
# Tenant Packs API — admin-grade pack management for the calling tenant
|
|
2634
|
+
#
|
|
2635
|
+
# These methods hit the *signed* marketplace endpoints — tenant identity
|
|
2636
|
+
# comes from the HMAC signature, so callers don't pass a tenant arg.
|
|
2637
|
+
# -------------------------------------------------------------------
|
|
2638
|
+
|
|
2639
|
+
def list_packs(self) -> Optional[Dict[str, Any]]:
|
|
2640
|
+
"""Return every active pack with eligibility + enablement annotations.
|
|
2641
|
+
|
|
2642
|
+
Response shape: ``{"packs": [...]}`` where each pack dict carries
|
|
2643
|
+
``eligible``, ``enabled``, ``prompt_count``, ``skill_count`` and the
|
|
2644
|
+
underlying ``AR Platform Pack`` fields.
|
|
2645
|
+
"""
|
|
2646
|
+
endpoint, params = self._prepare_list_packs()
|
|
2647
|
+
return self._request_get(endpoint, params, api_base=self.marketplace_api_base)
|
|
2648
|
+
|
|
2649
|
+
def get_pack_contents(self, pack_id: str) -> Optional[Dict[str, Any]]:
|
|
2650
|
+
"""Return the named prompts + skills of a pack the tenant owns.
|
|
2651
|
+
|
|
2652
|
+
Response: ``{"pack_id": str, "prompts": [{prompt_id,title,category}],
|
|
2653
|
+
"skills": [{skill_id,title,category,skill_type}]}``.
|
|
2654
|
+
"""
|
|
2655
|
+
endpoint, params = self._prepare_get_pack_contents(pack_id)
|
|
2656
|
+
return self._request_get(endpoint, params, api_base=self.marketplace_api_base)
|
|
2657
|
+
|
|
2658
|
+
def set_industry(
|
|
2659
|
+
self,
|
|
2660
|
+
industry: Optional[str],
|
|
2661
|
+
auto_enable: bool = True,
|
|
2662
|
+
) -> Optional[Dict[str, Any]]:
|
|
2663
|
+
"""Set the calling tenant's industry and optionally auto-enable its pack.
|
|
2664
|
+
|
|
2665
|
+
Pass an empty/None industry to clear (skip path). Returns
|
|
2666
|
+
``{"industry": str|None, "enabled_pack": str|None}``.
|
|
2667
|
+
"""
|
|
2668
|
+
endpoint, payload = self._prepare_set_industry(industry, auto_enable)
|
|
2669
|
+
return self._request_post_json(endpoint, payload, api_base=self.marketplace_api_base)
|
|
2670
|
+
|
|
2671
|
+
def set_pack_enabled(
|
|
2672
|
+
self,
|
|
2673
|
+
pack_id: str,
|
|
2674
|
+
enabled: bool,
|
|
2675
|
+
source: str = "User",
|
|
2676
|
+
) -> Optional[Dict[str, Any]]:
|
|
2677
|
+
"""Toggle a pack on or off for the calling tenant."""
|
|
2678
|
+
endpoint, payload = self._prepare_set_pack_enabled(pack_id, enabled, source)
|
|
2679
|
+
return self._request_post_json(endpoint, payload, api_base=self.marketplace_api_base)
|
|
2680
|
+
|
|
2681
|
+
def get_recommended_pack(
|
|
2682
|
+
self, user_id: Optional[str] = None,
|
|
2683
|
+
) -> Optional[Dict[str, Any]]:
|
|
2684
|
+
"""Return at most one pack to surface in the onboarding recommendation toast.
|
|
2685
|
+
|
|
2686
|
+
Returns ``{"pack": <dict|null>}``. Returns null when the user has
|
|
2687
|
+
already dismissed the toast, when there's no industry on the tenant,
|
|
2688
|
+
or when the recommended pack is already enabled.
|
|
2689
|
+
"""
|
|
2690
|
+
endpoint, params = self._prepare_get_recommended_pack(user_id)
|
|
2691
|
+
return self._request_get(endpoint, params, api_base=self.marketplace_api_base)
|
|
2692
|
+
|
|
2693
|
+
def dismiss_pack_recommendation(self, user_id: str) -> Optional[Dict[str, Any]]:
|
|
2694
|
+
"""Mark the recommendation toast as dismissed for the given user."""
|
|
2695
|
+
endpoint, payload = self._prepare_dismiss_pack_recommendation(user_id)
|
|
2696
|
+
return self._request_post_json(endpoint, payload, api_base=self.marketplace_api_base)
|
|
2697
|
+
|
|
2698
|
+
# -------------------------------------------------------------------
|
|
2699
|
+
# Pack acquisition + checkout (paid packs v2)
|
|
2700
|
+
# -------------------------------------------------------------------
|
|
2701
|
+
|
|
2702
|
+
def enable_pack_as_free_grant(
|
|
2703
|
+
self, pack_id: str,
|
|
2704
|
+
) -> Optional[Dict[str, Any]]:
|
|
2705
|
+
"""Activate a pack as the tenant's free-grant pick. Permanent."""
|
|
2706
|
+
endpoint, payload = self._prepare_enable_pack_as_free_grant(pack_id)
|
|
2707
|
+
return self._request_post_json(
|
|
2708
|
+
endpoint, payload, api_base=self.marketplace_api_base
|
|
2709
|
+
)
|
|
2710
|
+
|
|
2711
|
+
def grant_pack_as_admin(
|
|
2712
|
+
self, pack_id: str, granted_by_user: Optional[str] = None,
|
|
2713
|
+
) -> Optional[Dict[str, Any]]:
|
|
2714
|
+
"""Admin grant — does not consume free allowance."""
|
|
2715
|
+
endpoint, payload = self._prepare_grant_pack_as_admin(
|
|
2716
|
+
pack_id, granted_by_user
|
|
2717
|
+
)
|
|
2718
|
+
return self._request_post_json(
|
|
2719
|
+
endpoint, payload, api_base=self.marketplace_api_base
|
|
2720
|
+
)
|
|
2721
|
+
|
|
2722
|
+
def toggle_purchased_pack(
|
|
2723
|
+
self, pack_id: str, enabled: bool,
|
|
2724
|
+
) -> Optional[Dict[str, Any]]:
|
|
2725
|
+
"""Enable or disable a purchased pack."""
|
|
2726
|
+
endpoint, payload = self._prepare_toggle_purchased_pack(pack_id, enabled)
|
|
2727
|
+
return self._request_post_json(
|
|
2728
|
+
endpoint, payload, api_base=self.marketplace_api_base
|
|
2729
|
+
)
|
|
2730
|
+
|
|
2731
|
+
def list_pack_purchases(self) -> Optional[Dict[str, Any]]:
|
|
2732
|
+
"""Return the tenant's AR Pack Purchase history."""
|
|
2733
|
+
endpoint, params = self._prepare_list_pack_purchases()
|
|
2734
|
+
return self._request_get(
|
|
2735
|
+
endpoint, params, api_base=self.marketplace_api_base
|
|
2736
|
+
)
|
|
2737
|
+
|
|
2738
|
+
def initiate_pack_checkout(
|
|
2739
|
+
self, pack_id: str,
|
|
2740
|
+
) -> Optional[Dict[str, Any]]:
|
|
2741
|
+
"""Create a Razorpay one-time order for a pack purchase."""
|
|
2742
|
+
endpoint, payload = self._prepare_initiate_pack_checkout(pack_id)
|
|
2743
|
+
return self._request_post_json(
|
|
2744
|
+
endpoint, payload, api_base=self.billing_api_base
|
|
2745
|
+
)
|
|
2746
|
+
|
|
2747
|
+
def verify_razorpay_pack_payment(
|
|
2748
|
+
self,
|
|
2749
|
+
razorpay_payment_id: str,
|
|
2750
|
+
razorpay_order_id: str,
|
|
2751
|
+
razorpay_signature: str,
|
|
2752
|
+
) -> Optional[Dict[str, Any]]:
|
|
2753
|
+
"""Verify a Razorpay pack payment from the embedded widget."""
|
|
2754
|
+
endpoint, payload = self._prepare_verify_razorpay_pack_payment(
|
|
2755
|
+
razorpay_payment_id, razorpay_order_id, razorpay_signature
|
|
2756
|
+
)
|
|
2757
|
+
return self._request_post_json(
|
|
2758
|
+
endpoint, payload, api_base=self.billing_api_base
|
|
2759
|
+
)
|
|
2760
|
+
|
|
2761
|
+
# =========================================================================
|
|
2762
|
+
# HITL APIs
|
|
2763
|
+
# =========================================================================
|
|
2764
|
+
|
|
2765
|
+
def get_pending_interrupt(self, session_id: str, user_id: str) -> Optional[Dict[str, Any]]:
|
|
2766
|
+
"""
|
|
2767
|
+
Return the currently-pending HITL pause for `session_id`, or None.
|
|
2768
|
+
|
|
2769
|
+
Used by the FAC chat frontend on mount + on socket reconnect to
|
|
2770
|
+
rehydrate the InteractionCard after the user steps away or the
|
|
2771
|
+
socket times out.
|
|
2772
|
+
|
|
2773
|
+
Args:
|
|
2774
|
+
session_id: The AR session whose HITL pause to retrieve.
|
|
2775
|
+
user_id: The authenticated user on the FAC side (frappe.session.user).
|
|
2776
|
+
Checked against the persisted row's user_id on the AR side.
|
|
2777
|
+
|
|
2778
|
+
Response shape (when pending):
|
|
2779
|
+
{
|
|
2780
|
+
"pending": True,
|
|
2781
|
+
"session_id": str,
|
|
2782
|
+
"expires_at": str (ISO),
|
|
2783
|
+
"event": {
|
|
2784
|
+
"tool_id": str,
|
|
2785
|
+
"tool_name": str,
|
|
2786
|
+
"input": dict,
|
|
2787
|
+
"interrupts": [{"id", "name", "reason"}, ...]
|
|
2788
|
+
}
|
|
2789
|
+
}
|
|
2790
|
+
Or `{"pending": False}` when nothing is pending.
|
|
2791
|
+
Or None on transport error.
|
|
2792
|
+
"""
|
|
2793
|
+
endpoint, params = self._prepare_get_pending_interrupt(session_id, user_id)
|
|
2794
|
+
return self._request_get(endpoint, params, api_base=self.api_base)
|
|
2795
|
+
|
|
2796
|
+
def cancel_session(self, session_id: str) -> Optional[Dict[str, Any]]:
|
|
2797
|
+
"""Stop the session's active stream / clear its HITL pause."""
|
|
2798
|
+
endpoint, payload = self._prepare_cancel_session(session_id)
|
|
2799
|
+
return self._request_post_json(endpoint, payload, api_base=self.api_base)
|
|
2800
|
+
|
|
2801
|
+
# =========================================================================
|
|
2802
|
+
# Voice API
|
|
2803
|
+
# =========================================================================
|
|
2804
|
+
|
|
2805
|
+
def transcribe_audio(
|
|
2806
|
+
self,
|
|
2807
|
+
audio_bytes: bytes,
|
|
2808
|
+
mime_type: str = "audio/webm",
|
|
2809
|
+
user_id: Optional[str] = None,
|
|
2810
|
+
duration_ms: int = 0,
|
|
2811
|
+
language: Optional[str] = None,
|
|
2812
|
+
) -> Optional[Dict[str, Any]]:
|
|
2813
|
+
"""
|
|
2814
|
+
Transcribe an audio blob via AR's voice endpoint.
|
|
2815
|
+
|
|
2816
|
+
Args:
|
|
2817
|
+
audio_bytes: Raw audio bytes.
|
|
2818
|
+
mime_type: MIME type (default: audio/webm).
|
|
2819
|
+
user_id: User identifier for per-user billing attribution.
|
|
2820
|
+
duration_ms: Client-reported recording length (advisory only;
|
|
2821
|
+
billing uses the provider's authoritative duration).
|
|
2822
|
+
language: ISO-639-1 hint ("en", "es"…). Strongly recommended —
|
|
2823
|
+
without it Whisper auto-detects and frequently hallucinates
|
|
2824
|
+
outro phrases ("Thank you for watching") on silent clips.
|
|
2825
|
+
|
|
2826
|
+
Returns:
|
|
2827
|
+
{"text": str, "duration_seconds": float, "credits_consumed": float}
|
|
2828
|
+
"""
|
|
2829
|
+
params: Dict[str, Any] = {
|
|
2830
|
+
"tenant_id": self.tenant_id,
|
|
2831
|
+
"duration_ms": duration_ms,
|
|
2832
|
+
}
|
|
2833
|
+
if user_id:
|
|
2834
|
+
params["user_id"] = user_id
|
|
2835
|
+
if language:
|
|
2836
|
+
params["language"] = language
|
|
2837
|
+
return self._request_post_multipart(
|
|
2838
|
+
endpoint="transcribe_v1",
|
|
2839
|
+
params=params,
|
|
2840
|
+
file_field="audio",
|
|
2841
|
+
file_name="audio.webm",
|
|
2842
|
+
file_data=audio_bytes,
|
|
2843
|
+
content_type=mime_type,
|
|
2844
|
+
api_base=self.voice_api_base,
|
|
2845
|
+
)
|
|
2846
|
+
|
|
2847
|
+
|
|
2848
|
+
# =============================================================================
|
|
2849
|
+
# Standalone Functions
|
|
2850
|
+
# =============================================================================
|
|
2851
|
+
|
|
2852
|
+
|
|
2853
|
+
def get_terms(ar_url: str) -> Optional[Dict[str, Any]]:
|
|
2854
|
+
"""
|
|
2855
|
+
Fetch current Terms and Conditions from Assistant Runtime.
|
|
2856
|
+
|
|
2857
|
+
No authentication required - allows display before registration.
|
|
2858
|
+
|
|
2859
|
+
Args:
|
|
2860
|
+
ar_url: Assistant Runtime server URL
|
|
2861
|
+
|
|
2862
|
+
Returns:
|
|
2863
|
+
Terms content dict or None on failure
|
|
2864
|
+
"""
|
|
2865
|
+
url = f"{ar_url.rstrip('/')}/api/method/assistant_runtime.api.get_terms"
|
|
2866
|
+
|
|
2867
|
+
try:
|
|
2868
|
+
response = requests.get(url, timeout=30)
|
|
2869
|
+
response.raise_for_status()
|
|
2870
|
+
return response.json().get("message", response.json())
|
|
2871
|
+
except requests.exceptions.RequestException:
|
|
2872
|
+
return None
|
|
2873
|
+
|
|
2874
|
+
|
|
2875
|
+
def validate_referral_code(ar_url: str, referral_code: str) -> Dict[str, Any]:
|
|
2876
|
+
"""
|
|
2877
|
+
Validate a partner referral code against the AR server.
|
|
2878
|
+
|
|
2879
|
+
Guest-allowed endpoint — no tenant credentials required (used during signup).
|
|
2880
|
+
|
|
2881
|
+
Args:
|
|
2882
|
+
ar_url: Assistant Runtime server URL
|
|
2883
|
+
referral_code: The referral code to validate
|
|
2884
|
+
|
|
2885
|
+
Returns:
|
|
2886
|
+
{"valid": True, "partner_name": str, "referral_code": str} on success,
|
|
2887
|
+
{"valid": False, "error": str} on failure.
|
|
2888
|
+
"""
|
|
2889
|
+
url = f"{ar_url.rstrip('/')}/api/method/assistant_runtime.api.validate_referral_code"
|
|
2890
|
+
try:
|
|
2891
|
+
response = requests.post(
|
|
2892
|
+
url,
|
|
2893
|
+
json={"referral_code": referral_code},
|
|
2894
|
+
headers={"Content-Type": "application/json"},
|
|
2895
|
+
timeout=30,
|
|
2896
|
+
)
|
|
2897
|
+
response.raise_for_status()
|
|
2898
|
+
return response.json().get("message", response.json())
|
|
2899
|
+
except requests.exceptions.RequestException as e:
|
|
2900
|
+
return {"valid": False, "error": str(e)}
|
|
2901
|
+
|
|
2902
|
+
|
|
2903
|
+
def register_tenant(
|
|
2904
|
+
ar_url: str,
|
|
2905
|
+
site_url: str,
|
|
2906
|
+
owner_email: str,
|
|
2907
|
+
application_id: str,
|
|
2908
|
+
fac_mcp_endpoint: Optional[str] = None,
|
|
2909
|
+
terms_accepted: bool = True,
|
|
2910
|
+
terms_version: Optional[str] = None,
|
|
2911
|
+
accepted_by: Optional[str] = None,
|
|
2912
|
+
referral_code: Optional[str] = None,
|
|
2913
|
+
promotion_token: Optional[str] = None,
|
|
2914
|
+
) -> Dict[str, Any]:
|
|
2915
|
+
"""
|
|
2916
|
+
Register this installation as a tenant with Assistant Runtime.
|
|
2917
|
+
|
|
2918
|
+
Args:
|
|
2919
|
+
ar_url: Assistant Runtime server URL
|
|
2920
|
+
site_url: This site's URL (used for origin binding)
|
|
2921
|
+
owner_email: Email address of the tenant owner. Required. AR sends a
|
|
2922
|
+
verification email to this address; the tenant is not fully active
|
|
2923
|
+
until the owner confirms. After confirmation, call
|
|
2924
|
+
``get_initial_secret(ar_url, verification_token)`` with the token
|
|
2925
|
+
from the link to retrieve the ``tenant_secret``. The response from
|
|
2926
|
+
this function does **not** include the secret directly.
|
|
2927
|
+
application_id: The AR Application identifier this tenant belongs to
|
|
2928
|
+
(required, e.g. "faco"). AR hard-validates that a matching
|
|
2929
|
+
AR Application row exists and stores it on AR Tenant.application.
|
|
2930
|
+
fac_mcp_endpoint: Optional FAC MCP server endpoint URL
|
|
2931
|
+
terms_accepted: Must be True to register
|
|
2932
|
+
terms_version: Version of terms being accepted
|
|
2933
|
+
accepted_by: User who accepted the terms
|
|
2934
|
+
referral_code: Optional referral code
|
|
2935
|
+
promotion_token: Optional one-time waitlist promotion token. When an
|
|
2936
|
+
admin approves a waitlisted applicant, AR emails a resume link
|
|
2937
|
+
carrying this token; passing it here lets the applicant bypass the
|
|
2938
|
+
registration cap exactly once.
|
|
2939
|
+
|
|
2940
|
+
Returns:
|
|
2941
|
+
Registration result with tenant_id (secret delivered via email link), or error
|
|
2942
|
+
"""
|
|
2943
|
+
url = f"{ar_url.rstrip('/')}/api/method/assistant_runtime.api.register_tenant"
|
|
2944
|
+
|
|
2945
|
+
payload = {
|
|
2946
|
+
"site_url": site_url,
|
|
2947
|
+
"owner_email": owner_email,
|
|
2948
|
+
"application_id": application_id,
|
|
2949
|
+
"terms_accepted": terms_accepted,
|
|
2950
|
+
"terms_version": terms_version,
|
|
2951
|
+
"accepted_by": accepted_by,
|
|
2952
|
+
}
|
|
2953
|
+
|
|
2954
|
+
if fac_mcp_endpoint:
|
|
2955
|
+
payload["fac_endpoint"] = fac_mcp_endpoint
|
|
2956
|
+
|
|
2957
|
+
if referral_code:
|
|
2958
|
+
payload["referral_code"] = referral_code
|
|
2959
|
+
|
|
2960
|
+
if promotion_token:
|
|
2961
|
+
payload["promotion_token"] = promotion_token
|
|
2962
|
+
|
|
2963
|
+
try:
|
|
2964
|
+
response = requests.post(
|
|
2965
|
+
url,
|
|
2966
|
+
json=payload,
|
|
2967
|
+
headers={"Content-Type": "application/json"},
|
|
2968
|
+
timeout=60,
|
|
2969
|
+
)
|
|
2970
|
+
response.raise_for_status()
|
|
2971
|
+
return response.json().get("message", response.json())
|
|
2972
|
+
except requests.exceptions.RequestException as e:
|
|
2973
|
+
return {"error": str(e)}
|
|
2974
|
+
|
|
2975
|
+
|
|
2976
|
+
def get_registration_state(
|
|
2977
|
+
ar_url: str,
|
|
2978
|
+
site_url: str,
|
|
2979
|
+
tenant_id: Optional[str] = None,
|
|
2980
|
+
) -> Dict[str, Any]:
|
|
2981
|
+
"""Look up whether a tenant already exists for site_url (guest, no auth).
|
|
2982
|
+
|
|
2983
|
+
Returns {"exists": bool, "status": str, "owner_email_masked": str,
|
|
2984
|
+
"reregistration": bool} or {"error": str}. Never returns a secret.
|
|
2985
|
+
"""
|
|
2986
|
+
url = f"{ar_url.rstrip('/')}/api/method/assistant_runtime.api.get_registration_state"
|
|
2987
|
+
payload = {"site_url": site_url}
|
|
2988
|
+
if tenant_id:
|
|
2989
|
+
payload["tenant_id"] = tenant_id
|
|
2990
|
+
try:
|
|
2991
|
+
response = requests.post(
|
|
2992
|
+
url, json=payload,
|
|
2993
|
+
headers={"Content-Type": "application/json"}, timeout=30,
|
|
2994
|
+
)
|
|
2995
|
+
response.raise_for_status()
|
|
2996
|
+
return response.json().get("message", response.json())
|
|
2997
|
+
except requests.exceptions.RequestException as e:
|
|
2998
|
+
return {"error": str(e)}
|
|
2999
|
+
|
|
3000
|
+
|
|
3001
|
+
def get_initial_secret(ar_url: str, verification_token: str) -> Dict[str, Any]:
|
|
3002
|
+
"""One-shot retrieval of the freshly-minted tenant_secret after the
|
|
3003
|
+
owner has confirmed their email via the verification link.
|
|
3004
|
+
|
|
3005
|
+
Returns: {"tenant_secret": str} on success, or {"error": str} on failure.
|
|
3006
|
+
Subsequent calls with the same token return {"error": ...} (single use).
|
|
3007
|
+
"""
|
|
3008
|
+
url = f"{ar_url.rstrip('/')}/api/method/assistant_runtime.api.get_initial_secret"
|
|
3009
|
+
try:
|
|
3010
|
+
resp = requests.post(
|
|
3011
|
+
url,
|
|
3012
|
+
json={"verification_token": verification_token},
|
|
3013
|
+
headers={"Content-Type": "application/json"},
|
|
3014
|
+
timeout=30,
|
|
3015
|
+
)
|
|
3016
|
+
resp.raise_for_status()
|
|
3017
|
+
return resp.json().get("message", resp.json())
|
|
3018
|
+
except requests.exceptions.RequestException as e:
|
|
3019
|
+
return {"error": str(e)}
|
|
3020
|
+
|
|
3021
|
+
|
|
3022
|
+
def get_rotated_secret(ar_url: str, rebind_token: str) -> Dict[str, Any]:
|
|
3023
|
+
"""One-shot retrieval of the rotated tenant_secret after a rebind has
|
|
3024
|
+
been confirmed by the owner via the email link.
|
|
3025
|
+
|
|
3026
|
+
Returns: {"tenant_secret": str} on success, or {"error": str} on failure.
|
|
3027
|
+
"""
|
|
3028
|
+
url = f"{ar_url.rstrip('/')}/api/method/assistant_runtime.api.get_rotated_secret"
|
|
3029
|
+
try:
|
|
3030
|
+
resp = requests.post(
|
|
3031
|
+
url,
|
|
3032
|
+
json={"rebind_token": rebind_token},
|
|
3033
|
+
headers={"Content-Type": "application/json"},
|
|
3034
|
+
timeout=30,
|
|
3035
|
+
)
|
|
3036
|
+
resp.raise_for_status()
|
|
3037
|
+
return resp.json().get("message", resp.json())
|
|
3038
|
+
except requests.exceptions.RequestException as e:
|
|
3039
|
+
return {"error": str(e)}
|