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.
@@ -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)}