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,2277 @@
1
+ # Assistant Runtime SDK - Base Client
2
+ # Copyright (C) 2025 Paul Clinton
3
+ # AGPL-3.0 License
4
+
5
+ """
6
+ Base client class with shared authentication and configuration logic.
7
+
8
+ Both AssistantRuntimeClient (sync) and AsyncAssistantRuntimeClient (async) inherit from this.
9
+ """
10
+
11
+ import json
12
+ import logging
13
+ from typing import Dict, Any, List, Optional, Protocol, runtime_checkable
14
+
15
+ from .auth import generate_signature
16
+ from .streaming import parse_sse_line
17
+ from .exceptions import ARConfigurationError, ARBillingUnavailableError, ARAuthenticationError
18
+
19
+
20
+ @runtime_checkable
21
+ class Logger(Protocol):
22
+ """Protocol for logger interface."""
23
+
24
+ def error(self, msg: str, *args: Any, **kwargs: Any) -> None: ...
25
+ def warning(self, msg: str, *args: Any, **kwargs: Any) -> None: ...
26
+ def info(self, msg: str, *args: Any, **kwargs: Any) -> None: ...
27
+ def debug(self, msg: str, *args: Any, **kwargs: Any) -> None: ...
28
+
29
+
30
+ class BaseAssistantRuntimeClient:
31
+ """
32
+ Base class with shared authentication and configuration logic.
33
+
34
+ This class provides:
35
+ - HMAC signature generation for API requests
36
+ - SSE line parsing for streaming responses
37
+ - Common configuration and validation
38
+ - Multi API base routing (core + billing + memory + workflows)
39
+
40
+ Both AssistantRuntimeClient (sync) and AsyncAssistantRuntimeClient (async) inherit from this class.
41
+
42
+ Args:
43
+ tenant_id: Unique tenant identifier from Assistant Runtime
44
+ tenant_secret: HMAC secret for request signing
45
+ ar_url: Base URL of Assistant Runtime server (default: https://ar.example.com)
46
+ logger: Optional logger instance (uses stdlib logging if None)
47
+ timeout: Default request timeout in seconds (default: 30.0)
48
+ billing_api_base: Override URL for billing API endpoints. Defaults to
49
+ ``{ar_url}/api/method/assistant_runtime_payments.api``. Pass a full
50
+ URL or just a Frappe dotted module path (e.g.
51
+ ``"assistant_runtime_payments.api"``).
52
+ memory_api_base: Override URL for memory/onboarding API endpoints. Defaults to
53
+ ``{ar_url}/api/method/assistant_runtime_memory.api``. Pass a full
54
+ URL or just a Frappe dotted module path (e.g.
55
+ ``"assistant_runtime_memory.api"``).
56
+ workflows_api_base: Override URL for workflow API endpoints. Defaults to
57
+ ``{ar_url}/api/method/assistant_runtime_workflows.api``. Pass a full
58
+ URL or just a Frappe dotted module path (e.g.
59
+ ``"assistant_runtime_workflows.api"``).
60
+ marketplace_api_base: Override URL for marketplace API endpoints. Defaults to
61
+ ``{ar_url}/api/method/assistant_runtime_marketplace.api``. Pass a full
62
+ URL or just a Frappe dotted module path (e.g.
63
+ ``"assistant_runtime_marketplace.api"``).
64
+ site_url: This installation's canonical URL (e.g. ``"https://mysite.example.com"``).
65
+ When set, it is automatically injected into every signed payload so that
66
+ AR's ``validate_tenant_signature`` can enforce origin binding (Phase 2).
67
+ Pass ``None`` (default) to preserve backward-compatible behaviour.
68
+
69
+ Example:
70
+ >>> # Don't instantiate directly - use AssistantRuntimeClient or AsyncAssistantRuntimeClient
71
+ >>> from assistant_runtime_sdk import AssistantRuntimeClient
72
+ >>> client = AssistantRuntimeClient("tenant-id", "secret")
73
+ """
74
+
75
+ DEFAULT_AR_URL = "https://ar.example.com"
76
+ DEFAULT_BILLING_API_MODULE = "assistant_runtime_payments.api"
77
+ DEFAULT_MEMORY_API_MODULE = "assistant_runtime_memory.api"
78
+ DEFAULT_WORKFLOWS_API_MODULE = "assistant_runtime_workflows.api"
79
+ DEFAULT_MARKETPLACE_API_MODULE = "assistant_runtime_marketplace.api"
80
+ DEFAULT_VOICE_API_MODULE = "assistant_runtime.api.voice"
81
+ DEFAULT_TIMEOUT = 30.0
82
+ STREAM_CONNECT_TIMEOUT = 10.0
83
+ STREAM_READ_TIMEOUT = 300.0 # 5 minutes for long responses
84
+
85
+ def __init__(
86
+ self,
87
+ tenant_id: str,
88
+ tenant_secret: str,
89
+ ar_url: str = DEFAULT_AR_URL,
90
+ logger: Optional[Logger] = None,
91
+ timeout: float = DEFAULT_TIMEOUT,
92
+ billing_api_base: Optional[str] = None,
93
+ memory_api_base: Optional[str] = None,
94
+ workflows_api_base: Optional[str] = None,
95
+ marketplace_api_base: Optional[str] = None,
96
+ voice_api_base: Optional[str] = None,
97
+ site_url: Optional[str] = None,
98
+ ):
99
+ # Validate required parameters
100
+ if not tenant_id:
101
+ raise ARConfigurationError("tenant_id is required")
102
+ if not tenant_secret:
103
+ raise ARConfigurationError("tenant_secret is required")
104
+
105
+ self.tenant_id = tenant_id
106
+ self.tenant_secret = tenant_secret
107
+ self.site_url = site_url # Phase 2 origin binding: included in signed payloads when set
108
+ self.ar_url = ar_url.rstrip("/")
109
+ self.api_base = f"{self.ar_url}/api/method/assistant_runtime.api"
110
+ self.timeout = timeout
111
+
112
+ # Companion app API bases
113
+ self.billing_api_base = self._resolve_api_base(billing_api_base, self.DEFAULT_BILLING_API_MODULE)
114
+ self.memory_api_base = self._resolve_api_base(memory_api_base, self.DEFAULT_MEMORY_API_MODULE)
115
+ self.workflows_api_base = self._resolve_api_base(workflows_api_base, self.DEFAULT_WORKFLOWS_API_MODULE)
116
+ self.marketplace_api_base = self._resolve_api_base(marketplace_api_base, self.DEFAULT_MARKETPLACE_API_MODULE)
117
+ self.voice_api_base = self._resolve_api_base(voice_api_base, self.DEFAULT_VOICE_API_MODULE)
118
+
119
+ # Billing availability state — None means unknown (not yet probed)
120
+ self._billing_available: Optional[bool] = None
121
+
122
+ # Setup logger
123
+ if logger is None:
124
+ self.logger = logging.getLogger(__name__)
125
+ else:
126
+ self.logger = logger
127
+
128
+ def _resolve_api_base(self, override: Optional[str], default_module: str) -> str:
129
+ """Resolve an API base URL from an override or default module path."""
130
+ if override is None:
131
+ return f"{self.ar_url}/api/method/{default_module}"
132
+ if override.startswith(("http://", "https://")):
133
+ return override.rstrip("/")
134
+ return f"{self.ar_url}/api/method/{override}"
135
+
136
+ def _with_site_url(self, params: Dict[str, Any]) -> Dict[str, Any]:
137
+ """Return a copy of ``params`` with ``site_url`` injected.
138
+
139
+ Origin binding (Phase 2): AR rebuilds the HMAC body from the request
140
+ payload (form_dict / query string), so anything signed but not sent on
141
+ the wire fails verification. Call this at every signed entry point to
142
+ keep the signed and sent payloads identical.
143
+
144
+ Returns the same dict if ``site_url`` is already present or if the
145
+ client wasn't configured with one. Never mutates the input.
146
+ """
147
+ if self.site_url and "site_url" not in params:
148
+ return {**params, "site_url": self.site_url}
149
+ return params
150
+
151
+ def _generate_signature(self, params: Dict[str, Any], for_query_string: bool = False) -> str:
152
+ """
153
+ Generate HMAC-SHA256 signature for Assistant Runtime API request.
154
+
155
+ Args:
156
+ params: Request parameters (will be sorted by key)
157
+ for_query_string: If True, convert values to strings (for GET requests)
158
+
159
+ Returns:
160
+ Signature header value in format "timestamp:signature"
161
+ """
162
+ # Pre-Phase-2 callers may have stopped injecting site_url before signing —
163
+ # be defensive and mirror `_with_site_url` here too. Callers that already
164
+ # inserted site_url via `_with_site_url` will be a no-op.
165
+ params = self._with_site_url(params)
166
+ return generate_signature(
167
+ self.tenant_id,
168
+ self.tenant_secret,
169
+ params,
170
+ for_query_string=for_query_string,
171
+ )
172
+
173
+ def _get_headers(self, params: Dict[str, Any], for_query_string: bool = True) -> Dict[str, str]:
174
+ """
175
+ Get request headers with HMAC signature.
176
+
177
+ Args:
178
+ params: Request parameters for signature
179
+ for_query_string: True for GET requests, False for POST JSON
180
+
181
+ Returns:
182
+ Headers dict with X-AR-Signature
183
+ """
184
+ return {"X-AR-Signature": self._generate_signature(params, for_query_string)}
185
+
186
+ def _get_stream_headers(
187
+ self,
188
+ params: Dict[str, Any],
189
+ for_json_body: bool = False,
190
+ ) -> Dict[str, str]:
191
+ """
192
+ Get headers for SSE streaming requests.
193
+
194
+ Args:
195
+ params: Request parameters for signature
196
+ for_json_body: If True, signature is computed for JSON body (POST).
197
+ If False, signature is computed for query string (GET).
198
+
199
+ Returns:
200
+ Headers dict with X-AR-Signature and SSE headers
201
+ """
202
+ headers = {
203
+ "X-AR-Signature": self._generate_signature(params, for_query_string=not for_json_body),
204
+ "Accept": "text/event-stream",
205
+ "Cache-Control": "no-cache",
206
+ }
207
+ if for_json_body:
208
+ headers["Content-Type"] = "application/json"
209
+ return headers
210
+
211
+ def _parse_sse_line(self, line: str) -> Optional[Dict[str, Any]]:
212
+ """
213
+ Parse a single SSE line.
214
+
215
+ Args:
216
+ line: Raw SSE line
217
+
218
+ Returns:
219
+ Parsed event dict or None
220
+ """
221
+ return parse_sse_line(line)
222
+
223
+ def _build_endpoint_url(self, endpoint: str) -> str:
224
+ """
225
+ Build full URL for a core API endpoint.
226
+
227
+ Args:
228
+ endpoint: Endpoint path (e.g., 'streaming.stream_chat')
229
+
230
+ Returns:
231
+ Full URL
232
+ """
233
+ return f"{self.api_base}.{endpoint}"
234
+
235
+ def _build_billing_endpoint_url(self, endpoint: str) -> str:
236
+ """
237
+ Build full URL for a billing/payments API endpoint.
238
+
239
+ Routes through ``billing_api_base`` which targets the payments app.
240
+
241
+ Args:
242
+ endpoint: Endpoint path (e.g., 'get_usage_dashboard')
243
+
244
+ Returns:
245
+ Full URL
246
+ """
247
+ return f"{self.billing_api_base}.{endpoint}"
248
+
249
+ def _build_memory_endpoint_url(self, endpoint: str) -> str:
250
+ """
251
+ Build full URL for a memory/onboarding API endpoint.
252
+
253
+ Routes through ``memory_api_base`` which targets the memory app.
254
+
255
+ Args:
256
+ endpoint: Endpoint path (e.g., 'onboarding.get_onboarding_status')
257
+
258
+ Returns:
259
+ Full URL
260
+ """
261
+ return f"{self.memory_api_base}.{endpoint}"
262
+
263
+ def _build_workflows_endpoint_url(self, endpoint: str) -> str:
264
+ """
265
+ Build full URL for a workflow API endpoint.
266
+
267
+ Routes through ``workflows_api_base`` which targets the workflows app.
268
+
269
+ Args:
270
+ endpoint: Endpoint path (e.g., 'workflows.create_workflow')
271
+
272
+ Returns:
273
+ Full URL
274
+ """
275
+ return f"{self.workflows_api_base}.{endpoint}"
276
+
277
+ def _build_marketplace_endpoint_url(self, endpoint: str) -> str:
278
+ """
279
+ Build full URL for a marketplace API endpoint.
280
+
281
+ Routes through ``marketplace_api_base`` which targets the marketplace app.
282
+
283
+ Args:
284
+ endpoint: Endpoint path (e.g., 'listings.list_listings')
285
+
286
+ Returns:
287
+ Full URL
288
+ """
289
+ return f"{self.marketplace_api_base}.{endpoint}"
290
+
291
+ def _require_billing(self) -> None:
292
+ """
293
+ Guard for billing methods.
294
+
295
+ Raises ``ARBillingUnavailableError`` only if billing has been explicitly
296
+ probed and found unavailable (``_billing_available is False``). When the
297
+ state is unknown (``None``), the call proceeds — it will succeed if the
298
+ payments app is installed, or fail with an HTTP error if not.
299
+ """
300
+ if self._billing_available is False:
301
+ raise ARBillingUnavailableError()
302
+
303
+ def _prepare_stream_payload(
304
+ self,
305
+ session_id: str,
306
+ message: Optional[str],
307
+ user_id: str,
308
+ context: Optional[Dict[str, Any]] = None,
309
+ model_id: Optional[str] = None,
310
+ attachments: Optional[List[Dict[str, Any]]] = None,
311
+ system_prompt_addendum: Optional[str] = None,
312
+ client_type: Optional[str] = None,
313
+ interrupt_response: Optional[List[Dict[str, str]]] = None,
314
+ message_id: Optional[str] = None,
315
+ session_state: Optional[Dict[str, Any]] = None,
316
+ continue_from_message_id: Optional[str] = None,
317
+ web_search: Optional[bool] = None,
318
+ thinking_enabled: Optional[bool] = None,
319
+ ) -> Dict[str, Any]:
320
+ """
321
+ Prepare JSON payload for stream_chat POST request.
322
+
323
+ Args:
324
+ session_id: Conversation session identifier
325
+ message: User's message (optional when interrupt_response is provided)
326
+ user_id: User identifier (required)
327
+ context: Optional page context (sent as native dict, not JSON string)
328
+ model_id: Optional model ID
329
+ attachments: Optional list of attachments (images/documents)
330
+ Each attachment: {
331
+ "type": "image" | "document",
332
+ "format": "png" | "jpeg" | "gif" | "webp" | "pdf" | "txt",
333
+ "data": "<base64-encoded-data>",
334
+ "name": "optional-filename.png", # Optional
335
+ "file_url": "/files/..." # Optional, for storage reference
336
+ }
337
+ system_prompt_addendum: Optional per-request addition to the system prompt
338
+ client_type: Optional client type for tool filtering (widget/spa/mobile/api)
339
+ interrupt_response: Optional HITL resume responses. Each item:
340
+ {"interruptId": str, "response": "approve"|"rejected"|"trust"|"session"}
341
+ message_id: Optional message ID to reuse on HITL resume (ensures all
342
+ events across multiple resume cycles link to the same message)
343
+ session_state: Optional client-held signed session blob for
344
+ zero-retention conversations (sent up so the server can rehydrate
345
+ state without storing it server-side)
346
+ continue_from_message_id: Optional message ID to continue a
347
+ previous truncated response from
348
+ web_search: Optional explicit web-search toggle for this turn
349
+ thinking_enabled: Optional explicit extended-thinking toggle for this turn
350
+
351
+ Returns:
352
+ Payload dict ready for JSON body
353
+
354
+ Raises:
355
+ ARConfigurationError: If user_id is missing
356
+ """
357
+ if not user_id:
358
+ raise ARConfigurationError("user_id is required for stream_chat")
359
+
360
+ payload: Dict[str, Any] = {
361
+ "tenant_id": self.tenant_id,
362
+ "session_id": session_id,
363
+ "user_id": user_id,
364
+ }
365
+
366
+ if message:
367
+ payload["message"] = message
368
+
369
+ if context:
370
+ # Context is sent as a native dict in JSON body, not a JSON string
371
+ payload["context"] = context
372
+
373
+ if model_id:
374
+ payload["model_id"] = model_id
375
+
376
+ if attachments:
377
+ payload["attachments"] = attachments
378
+
379
+ if system_prompt_addendum:
380
+ payload["system_prompt_addendum"] = system_prompt_addendum
381
+
382
+ if client_type:
383
+ payload["client_type"] = client_type
384
+
385
+ if interrupt_response:
386
+ payload["interrupt_response"] = interrupt_response
387
+
388
+ if message_id:
389
+ payload["message_id"] = message_id
390
+
391
+ if session_state:
392
+ payload["session_state"] = session_state
393
+
394
+ if continue_from_message_id:
395
+ payload["continue_from_message_id"] = continue_from_message_id
396
+
397
+ if web_search is not None:
398
+ payload["web_search"] = bool(web_search)
399
+
400
+ if thinking_enabled is not None:
401
+ payload["thinking_enabled"] = bool(thinking_enabled)
402
+
403
+ return payload
404
+
405
+ def _prepare_resource_params(
406
+ self,
407
+ user_id: str,
408
+ uri: Optional[str] = None,
409
+ server: Optional[str] = None,
410
+ ) -> Dict[str, Any]:
411
+ """
412
+ Prepare parameters for resource requests.
413
+
414
+ Args:
415
+ user_id: User identifier (required)
416
+ uri: Optional resource URI (for read_resource)
417
+ server: Optional server name filter (for list_resources)
418
+
419
+ Returns:
420
+ Parameters dict ready for request
421
+ """
422
+ params: Dict[str, Any] = {
423
+ "tenant_id": self.tenant_id,
424
+ "user_id": user_id,
425
+ }
426
+ if uri:
427
+ params["uri"] = uri
428
+ if server:
429
+ params["server"] = server
430
+ return params
431
+
432
+ def _log_error(self, message: str, category: str = "AR") -> None:
433
+ """Log an error message."""
434
+ self.logger.error(message, extra={"category": category})
435
+
436
+ def _log_warning(self, message: str, category: str = "AR") -> None:
437
+ """Log a warning message."""
438
+ self.logger.warning(message, extra={"category": category})
439
+
440
+ def _log_debug(self, message: str, category: str = "AR") -> None:
441
+ """Log a debug message."""
442
+ self.logger.debug(message, extra={"category": category})
443
+
444
+ # =========================================================================
445
+ # Error Extraction
446
+ # =========================================================================
447
+
448
+ @staticmethod
449
+ def _extract_error_from_data(data: dict) -> Optional[str]:
450
+ """Extract a human-readable error message from a Frappe error response dict.
451
+
452
+ Works with both sync and async clients since it accepts a parsed dict
453
+ rather than a library-specific response object.
454
+ """
455
+ try:
456
+ server_messages = data.get("_server_messages")
457
+ if server_messages:
458
+ messages = json.loads(server_messages)
459
+ if messages:
460
+ msg = json.loads(messages[0])
461
+ return msg.get("message", str(msg))
462
+ if data.get("exception"):
463
+ exc = data["exception"]
464
+ lines = exc.strip().splitlines()
465
+ if lines:
466
+ last = lines[-1]
467
+ if ": " in last:
468
+ return last.split(": ", 1)[1]
469
+ return last
470
+ if data.get("message"):
471
+ return data["message"]
472
+ except Exception:
473
+ pass
474
+ return None
475
+
476
+ # =========================================================================
477
+ # Prepare Methods — Tenant
478
+ # =========================================================================
479
+
480
+ def _prepare_get_tenant_info(self) -> tuple:
481
+ """Returns (endpoint, params)."""
482
+ return "get_tenant_info", {"tenant_id": self.tenant_id}
483
+
484
+ def _prepare_accept_terms(self, terms_version: str, accepted_by: str) -> tuple:
485
+ """Returns (endpoint, payload)."""
486
+ return "accept_terms", {
487
+ "tenant_id": self.tenant_id,
488
+ "terms_version": terms_version,
489
+ "accepted_by": accepted_by,
490
+ }
491
+
492
+ def _prepare_get_terms_status(self) -> tuple:
493
+ """Returns (endpoint, params)."""
494
+ return "get_terms_status", {"tenant_id": self.tenant_id}
495
+
496
+ # =========================================================================
497
+ # Prepare Methods — Models
498
+ # =========================================================================
499
+
500
+ def _prepare_list_available_models(self) -> tuple:
501
+ return "streaming.list_available_models", {"tenant_id": self.tenant_id}
502
+
503
+ def _prepare_get_available_models(self) -> tuple:
504
+ return "get_available_models", {"tenant_id": self.tenant_id}
505
+
506
+ def _prepare_set_preferred_model(self, model_id: str) -> tuple:
507
+ return "set_preferred_model", {"tenant_id": self.tenant_id, "model_id": model_id}
508
+
509
+ # =========================================================================
510
+ # Prepare Methods — Prompts & Suggestions
511
+ # =========================================================================
512
+
513
+ def _prepare_list_prompts(self, user_id: str, cursor: Optional[str] = None) -> tuple:
514
+ params = {"tenant_id": self.tenant_id, "user_id": user_id}
515
+ if cursor:
516
+ params["cursor"] = cursor
517
+ return "prompts.list_prompts", params
518
+
519
+ def _prepare_get_prompt(self, prompt_name: str, user_id: str,
520
+ arguments: Optional[Dict[str, Any]] = None) -> tuple:
521
+ payload = {
522
+ "tenant_id": self.tenant_id,
523
+ "user_id": user_id,
524
+ "prompt_name": prompt_name,
525
+ }
526
+ if arguments:
527
+ payload["arguments"] = arguments
528
+ return "prompts.get_prompt", payload
529
+
530
+ def _prepare_get_suggestions(self, user_id: str,
531
+ context: Optional[Dict[str, Any]] = None,
532
+ limit: int = 8) -> tuple:
533
+ params: Dict[str, Any] = {
534
+ "tenant_id": self.tenant_id,
535
+ "user_id": user_id,
536
+ "limit": str(limit),
537
+ }
538
+ if context:
539
+ params["context"] = json.dumps(context)
540
+ return "suggestions.get_suggestions", params
541
+
542
+ def _prepare_generate_curated_suggestions(
543
+ self, user_id: str, signals: Optional[Dict[str, Any]] = None
544
+ ) -> tuple:
545
+ payload: Dict[str, Any] = {
546
+ "tenant_id": self.tenant_id,
547
+ "user_id": user_id,
548
+ "signals": signals or {},
549
+ }
550
+ return "suggestions.generate_curated", payload
551
+
552
+ # =========================================================================
553
+ # Prepare Methods — Onboarding
554
+ # =========================================================================
555
+
556
+ def _prepare_get_onboarding_status(self, user_id: str) -> tuple:
557
+ return "onboarding.get_onboarding_status", {
558
+ "tenant_id": self.tenant_id,
559
+ "user_id": user_id,
560
+ }
561
+
562
+ def _prepare_complete_onboarding(self, user_id: str) -> tuple:
563
+ payload: Dict[str, Any] = {
564
+ "tenant_id": self.tenant_id,
565
+ "user_id": user_id,
566
+ }
567
+ return "onboarding.complete_onboarding", payload
568
+
569
+ # =========================================================================
570
+ # Prepare Methods — HITL
571
+ # =========================================================================
572
+
573
+ def _prepare_get_pending_interrupt(self, session_id: str, user_id: str) -> tuple:
574
+ if not session_id:
575
+ raise ValueError("session_id is required")
576
+ if not user_id:
577
+ raise ValueError("user_id is required")
578
+ return "hitl.get_pending_interrupt", {
579
+ "tenant_id": self.tenant_id,
580
+ "user_id": user_id,
581
+ "session_id": session_id,
582
+ }
583
+
584
+ def _prepare_cancel_session(self, session_id: str) -> tuple:
585
+ """Returns (endpoint, payload)."""
586
+ if not session_id:
587
+ raise ValueError("session_id is required")
588
+ return "cancel.cancel_session", {
589
+ "tenant_id": self.tenant_id,
590
+ "session_id": session_id,
591
+ }
592
+
593
+ # =========================================================================
594
+ # Prepare Methods — Documents
595
+ # =========================================================================
596
+
597
+ def _prepare_upload_document(
598
+ self,
599
+ file_path: Optional[str] = None,
600
+ file_data: Optional[bytes] = None,
601
+ file_name: Optional[str] = None,
602
+ content_type: Optional[str] = None,
603
+ user_id: Optional[str] = None,
604
+ visibility: Optional[str] = None,
605
+ shared_with: Optional[List[str]] = None,
606
+ ) -> tuple:
607
+ """Returns (endpoint, params, file_field, file_name, file_data, content_type)."""
608
+ import mimetypes as _mt
609
+ import os as _os
610
+
611
+ if file_path and file_data:
612
+ raise ARConfigurationError("Provide either file_path or file_data, not both")
613
+ if not file_path and not file_data:
614
+ raise ARConfigurationError("Either file_path or file_data is required")
615
+ if file_data and not file_name:
616
+ raise ARConfigurationError("file_name is required when using file_data")
617
+
618
+ if visibility and visibility not in ("public", "private", "shared"):
619
+ raise ARConfigurationError(
620
+ f"Invalid visibility: {visibility}. Must be public, private, or shared."
621
+ )
622
+ if visibility == "shared" and not shared_with:
623
+ raise ARConfigurationError(
624
+ "shared_with is required when visibility is 'shared'"
625
+ )
626
+
627
+ if file_path:
628
+ with open(file_path, "rb") as f:
629
+ data = f.read()
630
+ if not file_name:
631
+ file_name = _os.path.basename(file_path)
632
+ else:
633
+ data = file_data
634
+
635
+ if not content_type:
636
+ guessed, _ = _mt.guess_type(file_name)
637
+ content_type = guessed or "application/octet-stream"
638
+
639
+ params: Dict[str, Any] = {"tenant_id": self.tenant_id}
640
+ if user_id:
641
+ params["user_id"] = user_id
642
+ if visibility:
643
+ params["visibility"] = visibility
644
+ if shared_with:
645
+ params["shared_with"] = json.dumps(shared_with)
646
+
647
+ return (
648
+ "documents.upload_document",
649
+ params,
650
+ "file",
651
+ file_name,
652
+ data,
653
+ content_type,
654
+ )
655
+
656
+ def _prepare_list_documents(
657
+ self, limit: int = 50, offset: int = 0, user_id: Optional[str] = None
658
+ ) -> tuple:
659
+ params: Dict[str, Any] = {
660
+ "tenant_id": self.tenant_id,
661
+ "limit": str(limit),
662
+ "offset": str(offset),
663
+ }
664
+ if user_id:
665
+ params["user_id"] = user_id
666
+ return "documents.list_documents", params
667
+
668
+ def _prepare_get_document(self, document_id: str) -> tuple:
669
+ return "documents.get_document", {
670
+ "tenant_id": self.tenant_id,
671
+ "document_id": document_id,
672
+ }
673
+
674
+ def _prepare_list_chunks(
675
+ self,
676
+ document_id: str,
677
+ user_id: Optional[str] = None,
678
+ search: Optional[str] = None,
679
+ limit: int = 50,
680
+ offset: int = 0,
681
+ ) -> tuple:
682
+ params: Dict[str, Any] = {
683
+ "tenant_id": self.tenant_id,
684
+ "document_id": document_id,
685
+ "limit": str(limit),
686
+ "offset": str(offset),
687
+ }
688
+ if user_id:
689
+ params["user_id"] = user_id
690
+ if search:
691
+ params["search"] = search
692
+ return "documents.list_chunks", params
693
+
694
+ def _prepare_get_document_content(
695
+ self, document_id: str, user_id: Optional[str] = None, download: bool = False
696
+ ) -> tuple:
697
+ params: Dict[str, Any] = {
698
+ "tenant_id": self.tenant_id,
699
+ "document_id": document_id,
700
+ }
701
+ if user_id:
702
+ params["user_id"] = user_id
703
+ if download:
704
+ params["download"] = "1"
705
+ return "documents.get_document_content", params
706
+
707
+ def _prepare_delete_document(
708
+ self, document_id: str, user_id: Optional[str] = None
709
+ ) -> tuple:
710
+ payload: Dict[str, Any] = {
711
+ "tenant_id": self.tenant_id,
712
+ "document_id": document_id,
713
+ }
714
+ if user_id:
715
+ payload["user_id"] = user_id
716
+ return "documents.delete_document", payload
717
+
718
+ def _prepare_get_storage_info(self) -> tuple:
719
+ return "documents.get_storage_info", {"tenant_id": self.tenant_id}
720
+
721
+ def _prepare_update_document_access(
722
+ self,
723
+ document_id: str,
724
+ user_id: str,
725
+ visibility: Optional[str] = None,
726
+ add_users: Optional[List[str]] = None,
727
+ remove_users: Optional[List[str]] = None,
728
+ ) -> tuple:
729
+ """Returns (endpoint, payload) for updating document visibility/sharing."""
730
+ payload: Dict[str, Any] = {
731
+ "tenant_id": self.tenant_id,
732
+ "document_id": document_id,
733
+ "user_id": user_id,
734
+ }
735
+ if visibility:
736
+ payload["visibility"] = visibility
737
+ if add_users:
738
+ payload["add_users"] = json.dumps(add_users)
739
+ if remove_users:
740
+ payload["remove_users"] = json.dumps(remove_users)
741
+ return "documents.update_document_access", payload
742
+
743
+ # =========================================================================
744
+ # Prepare Methods — Memories
745
+ # =========================================================================
746
+
747
+ def _prepare_list_memories(
748
+ self, user_id: Optional[str] = None,
749
+ memory_type: Optional[str] = None,
750
+ limit: int = 50, offset: int = 0
751
+ ) -> tuple:
752
+ params: Dict[str, Any] = {
753
+ "tenant_id": self.tenant_id,
754
+ "limit": str(limit),
755
+ "offset": str(offset),
756
+ }
757
+ if user_id:
758
+ params["user_id"] = user_id
759
+ if memory_type:
760
+ params["memory_type"] = memory_type
761
+ return "memories.list_memories", params
762
+
763
+ def _prepare_delete_memory(self, user_id: str, memory_id: str) -> tuple:
764
+ return "memories.delete_memory", {
765
+ "tenant_id": self.tenant_id,
766
+ "user_id": user_id,
767
+ "memory_id": memory_id,
768
+ }
769
+
770
+ def _prepare_delete_all_memories(self, user_id: str) -> tuple:
771
+ return "memories.delete_all_memories", {
772
+ "tenant_id": self.tenant_id,
773
+ "user_id": user_id,
774
+ }
775
+
776
+ def _prepare_update_memory(self, user_id: str, memory_id: str, content: str) -> tuple:
777
+ return "memories.update_memory", {
778
+ "tenant_id": self.tenant_id,
779
+ "user_id": user_id,
780
+ "memory_id": memory_id,
781
+ "content": content,
782
+ }
783
+
784
+ def _prepare_get_memory_stats(self, user_id: Optional[str] = None) -> tuple:
785
+ params: Dict[str, Any] = {"tenant_id": self.tenant_id}
786
+ if user_id:
787
+ params["user_id"] = user_id
788
+ return "memories.get_memory_stats", params
789
+
790
+ def _prepare_get_memory_summary(self, user_id: str, force: bool = False) -> tuple:
791
+ params: Dict[str, Any] = {"tenant_id": self.tenant_id, "user_id": user_id}
792
+ if force:
793
+ params["force"] = "1"
794
+ return "memories.get_memory_summary", params
795
+
796
+ # =========================================================================
797
+ # Prepare Methods — Shared Knowledge
798
+ # =========================================================================
799
+
800
+ def _prepare_get_shared_knowledge(self) -> tuple:
801
+ return "shared_knowledge.get_shared_knowledge", {
802
+ "tenant_id": self.tenant_id,
803
+ }
804
+
805
+ def _prepare_update_shared_knowledge(self, content: str) -> tuple:
806
+ return "shared_knowledge.update_shared_knowledge", {
807
+ "tenant_id": self.tenant_id,
808
+ "content": content,
809
+ }
810
+
811
+ def _prepare_share_memory_to_knowledge(self, user_id: str, memory_id: str) -> tuple:
812
+ return "shared_knowledge.share_memory", {
813
+ "tenant_id": self.tenant_id,
814
+ "user_id": user_id,
815
+ "memory_id": memory_id,
816
+ }
817
+
818
+ # =========================================================================
819
+ # Prepare Methods — Tools
820
+ # =========================================================================
821
+
822
+ def _prepare_list_tools(self, user_id: str,
823
+ server: Optional[str] = None) -> tuple:
824
+ params: Dict[str, Any] = {"tenant_id": self.tenant_id, "user_id": user_id}
825
+ if server:
826
+ params["server"] = server
827
+ return "tools.list_tools", params
828
+
829
+ # =========================================================================
830
+ # Prepare Methods — Tool Preferences (per-user approval settings)
831
+ # =========================================================================
832
+
833
+ def _prepare_list_tool_preferences(self, user_id: str) -> tuple:
834
+ return "tool_preferences.list_tool_preferences", {
835
+ "tenant_id": self.tenant_id,
836
+ "user_id": user_id,
837
+ }
838
+
839
+ def _prepare_set_tool_preference(
840
+ self, user_id: str, tool_name: str, preference: str,
841
+ ) -> tuple:
842
+ return "tool_preferences.set_tool_preference", {
843
+ "tenant_id": self.tenant_id,
844
+ "user_id": user_id,
845
+ "tool_name": tool_name,
846
+ "preference": preference,
847
+ }
848
+
849
+ # =========================================================================
850
+ # Prepare Methods — Billing
851
+ # =========================================================================
852
+
853
+ def _prepare_get_recommended_gateway(self) -> tuple:
854
+ self._require_billing()
855
+ return "get_recommended_gateway", {"tenant_id": self.tenant_id}
856
+
857
+ def _prepare_get_available_gateways(self) -> tuple:
858
+ self._require_billing()
859
+ return "get_available_gateways", {"tenant_id": self.tenant_id}
860
+
861
+ def _prepare_preview_plan_pricing(
862
+ self, plan: str, billing_cycle: str = "monthly",
863
+ ) -> tuple:
864
+ self._require_billing()
865
+ return "preview_plan_pricing", {
866
+ "tenant_id": self.tenant_id,
867
+ "plan": plan,
868
+ "billing_cycle": billing_cycle,
869
+ }
870
+
871
+ def _prepare_download_invoice_pdf(self, ar_invoice_name: str) -> tuple:
872
+ self._require_billing()
873
+ return "download_invoice_pdf", {
874
+ "tenant_id": self.tenant_id,
875
+ "ar_invoice_name": ar_invoice_name,
876
+ }
877
+
878
+ def _prepare_add_user_seat(self) -> tuple:
879
+ self._require_billing()
880
+ return "add_user_seat", {"tenant_id": self.tenant_id}
881
+
882
+ def _prepare_remove_user_seat(self) -> tuple:
883
+ self._require_billing()
884
+ return "remove_user_seat", {"tenant_id": self.tenant_id}
885
+
886
+ def _prepare_preview_seat_charge(self) -> tuple:
887
+ self._require_billing()
888
+ return "preview_seat_charge", {"tenant_id": self.tenant_id}
889
+
890
+ def _prepare_initiate_checkout(
891
+ self, plan: str, billing_cycle: str = "monthly",
892
+ gateway: Optional[str] = None, billing_name: Optional[str] = None,
893
+ billing_email: Optional[str] = None,
894
+ ) -> tuple:
895
+ self._require_billing()
896
+ payload: Dict[str, Any] = {
897
+ "tenant_id": self.tenant_id,
898
+ "plan": plan,
899
+ "billing_cycle": billing_cycle,
900
+ }
901
+ if gateway:
902
+ payload["gateway"] = gateway
903
+ if billing_name:
904
+ payload["billing_name"] = billing_name
905
+ if billing_email:
906
+ payload["billing_email"] = billing_email
907
+ return "initiate_checkout", payload
908
+
909
+ def _prepare_verify_checkout(self, session_id: Optional[str] = None) -> tuple:
910
+ self._require_billing()
911
+ payload: Dict[str, Any] = {"tenant_id": self.tenant_id}
912
+ if session_id:
913
+ payload["session_id"] = session_id
914
+ return "verify_checkout", payload
915
+
916
+ def _prepare_verify_razorpay_payment(
917
+ self, razorpay_payment_id: str,
918
+ razorpay_subscription_id: str, razorpay_signature: str,
919
+ ) -> tuple:
920
+ self._require_billing()
921
+ return "verify_razorpay_payment", {
922
+ "tenant_id": self.tenant_id,
923
+ "razorpay_payment_id": razorpay_payment_id,
924
+ "razorpay_subscription_id": razorpay_subscription_id,
925
+ "razorpay_signature": razorpay_signature,
926
+ }
927
+
928
+ def _prepare_verify_razorpay_credit_payment(
929
+ self, razorpay_payment_id: str,
930
+ razorpay_order_id: str, razorpay_signature: str,
931
+ ) -> tuple:
932
+ self._require_billing()
933
+ return "verify_razorpay_credit_payment", {
934
+ "tenant_id": self.tenant_id,
935
+ "razorpay_payment_id": razorpay_payment_id,
936
+ "razorpay_order_id": razorpay_order_id,
937
+ "razorpay_signature": razorpay_signature,
938
+ }
939
+
940
+ def _prepare_verify_seat_payment(
941
+ self, razorpay_payment_id: str,
942
+ razorpay_order_id: str, razorpay_signature: str,
943
+ ) -> tuple:
944
+ self._require_billing()
945
+ return "verify_seat_payment", {
946
+ "tenant_id": self.tenant_id,
947
+ "razorpay_payment_id": razorpay_payment_id,
948
+ "razorpay_order_id": razorpay_order_id,
949
+ "razorpay_signature": razorpay_signature,
950
+ }
951
+
952
+ def _prepare_get_token_analytics(self, days: int = 30, user_id: str = None) -> tuple:
953
+ params = {"tenant_id": self.tenant_id, "days": days}
954
+ if user_id:
955
+ params["user_id"] = user_id
956
+ return "usage.get_token_analytics", params
957
+
958
+ def _prepare_get_conversation_analytics(
959
+ self, user_id: str = None, days: int = 30, limit: int = 50, offset: int = 0,
960
+ ) -> tuple:
961
+ params = {"tenant_id": self.tenant_id, "days": days, "limit": limit, "offset": offset}
962
+ if user_id:
963
+ params["user_id"] = user_id
964
+ return "usage.get_conversation_analytics", params
965
+
966
+ def _prepare_get_message_credits(self, conversation_id: str, user_id: str = None) -> tuple:
967
+ params = {"tenant_id": self.tenant_id, "conversation_id": conversation_id}
968
+ if user_id:
969
+ params["user_id"] = user_id
970
+ return "usage.get_message_credits", params
971
+
972
+ def _prepare_get_usage_dashboard(self) -> tuple:
973
+ self._require_billing()
974
+ return "get_usage_dashboard", {"tenant_id": self.tenant_id}
975
+
976
+ def _prepare_get_usage_history(self, days: int = 30) -> tuple:
977
+ self._require_billing()
978
+ return "get_usage_history", {"tenant_id": self.tenant_id, "days": days}
979
+
980
+ def _prepare_get_invoices(self, limit: int = 10) -> tuple:
981
+ self._require_billing()
982
+ return "get_invoices", {"tenant_id": self.tenant_id, "limit": limit}
983
+
984
+ def _prepare_get_upcoming_invoice(self) -> tuple:
985
+ self._require_billing()
986
+ return "get_upcoming_invoice", {"tenant_id": self.tenant_id}
987
+
988
+ def _prepare_get_payment_methods(self) -> tuple:
989
+ self._require_billing()
990
+ return "get_payment_methods", {"tenant_id": self.tenant_id}
991
+
992
+ def _prepare_upgrade_plan(
993
+ self, new_plan: str, billing_cycle: str = "monthly",
994
+ gateway: Optional[str] = None, billing_name: Optional[str] = None,
995
+ billing_email: Optional[str] = None,
996
+ promo_code: Optional[str] = None,
997
+ payment_method: Optional[str] = None,
998
+ ) -> tuple:
999
+ self._require_billing()
1000
+ payload: Dict[str, Any] = {
1001
+ "tenant_id": self.tenant_id,
1002
+ "new_plan": new_plan,
1003
+ "billing_cycle": billing_cycle,
1004
+ }
1005
+ if gateway:
1006
+ payload["gateway"] = gateway
1007
+ if billing_name:
1008
+ payload["billing_name"] = billing_name
1009
+ if billing_email:
1010
+ payload["billing_email"] = billing_email
1011
+ if promo_code:
1012
+ payload["promo_code"] = promo_code
1013
+ if payment_method:
1014
+ payload["payment_method"] = payment_method
1015
+ return "upgrade_plan", payload
1016
+
1017
+ def _prepare_reauthorize_mandate(
1018
+ self,
1019
+ billing_name: Optional[str] = None,
1020
+ payment_method: Optional[str] = None,
1021
+ ) -> tuple:
1022
+ """Build the request for ``reauthorize_mandate`` — re-authorize the
1023
+ saved Razorpay mandate without changing plans. Used when the
1024
+ renewal cron has flagged the mandate as exhausted (e.g., projected
1025
+ next-cycle debit exceeds UPI Autopay's per-debit ceiling)."""
1026
+ self._require_billing()
1027
+ payload: Dict[str, Any] = {"tenant_id": self.tenant_id}
1028
+ if billing_name:
1029
+ payload["billing_name"] = billing_name
1030
+ if payment_method:
1031
+ payload["payment_method"] = payment_method
1032
+ return "reauthorize_mandate", payload
1033
+
1034
+ def _prepare_validate_promo_code(
1035
+ self, promo_code: str, plan: Optional[str] = None,
1036
+ ) -> tuple:
1037
+ self._require_billing()
1038
+ payload: Dict[str, Any] = {
1039
+ "tenant_id": self.tenant_id,
1040
+ "promo_code": promo_code,
1041
+ }
1042
+ if plan:
1043
+ payload["plan"] = plan
1044
+ return "promotions.validate_promo_code", payload
1045
+
1046
+ def _prepare_downgrade_to_free(self) -> tuple:
1047
+ self._require_billing()
1048
+ return "downgrade_to_free", {"tenant_id": self.tenant_id}
1049
+
1050
+ def _prepare_cancel_scheduled_change(self) -> tuple:
1051
+ self._require_billing()
1052
+ return "cancel_scheduled_change", {"tenant_id": self.tenant_id}
1053
+
1054
+ def _prepare_cancel_subscription(self, cancel_immediately: bool = False) -> tuple:
1055
+ self._require_billing()
1056
+ return "cancel_subscription", {
1057
+ "tenant_id": self.tenant_id,
1058
+ "cancel_immediately": cancel_immediately,
1059
+ }
1060
+
1061
+ def _prepare_reactivate_subscription(self) -> tuple:
1062
+ self._require_billing()
1063
+ return "reactivate_subscription", {"tenant_id": self.tenant_id}
1064
+
1065
+ def _prepare_pause_subscription(self) -> tuple:
1066
+ self._require_billing()
1067
+ return "pause_subscription", {"tenant_id": self.tenant_id}
1068
+
1069
+ def _prepare_resume_subscription(self) -> tuple:
1070
+ self._require_billing()
1071
+ return "resume_subscription", {"tenant_id": self.tenant_id}
1072
+
1073
+ def _prepare_update_payment_method(self) -> tuple:
1074
+ self._require_billing()
1075
+ return "update_payment_method", {"tenant_id": self.tenant_id}
1076
+
1077
+ def _prepare_get_subscription_status(self) -> tuple:
1078
+ self._require_billing()
1079
+ return "get_subscription_status", {"tenant_id": self.tenant_id}
1080
+
1081
+ def _prepare_get_billing_history(self, limit: int = 20) -> tuple:
1082
+ self._require_billing()
1083
+ return "get_billing_history", {"tenant_id": self.tenant_id, "limit": limit}
1084
+
1085
+ def _prepare_get_credit_balance(self) -> tuple:
1086
+ self._require_billing()
1087
+ return "get_credit_balance", {"tenant_id": self.tenant_id}
1088
+
1089
+ def _prepare_purchase_credits(self, credit_amount: int,
1090
+ gateway: Optional[str] = None) -> tuple:
1091
+ self._require_billing()
1092
+ payload: Dict[str, Any] = {
1093
+ "tenant_id": self.tenant_id,
1094
+ "credit_amount": credit_amount,
1095
+ }
1096
+ if gateway:
1097
+ payload["gateway"] = gateway
1098
+ return "purchase_credits", payload
1099
+
1100
+ def _prepare_get_expiring_credits(self) -> tuple:
1101
+ self._require_billing()
1102
+ return "get_expiring_credits", {"tenant_id": self.tenant_id}
1103
+
1104
+ def _prepare_get_consumption_breakdown(self, days: int = 30) -> tuple:
1105
+ self._require_billing()
1106
+ return "get_consumption_breakdown", {
1107
+ "tenant_id": self.tenant_id,
1108
+ "days": int(days),
1109
+ }
1110
+
1111
+ def _prepare_get_billing_details(self) -> tuple:
1112
+ self._require_billing()
1113
+ return "billing_details.get_billing_details", {"tenant_id": self.tenant_id}
1114
+
1115
+ def _prepare_save_billing_details(self, billing_fields: Dict[str, Any]) -> tuple:
1116
+ self._require_billing()
1117
+ payload: Dict[str, Any] = {"tenant_id": self.tenant_id, **billing_fields}
1118
+ return "billing_details.save_billing_details", payload
1119
+
1120
+ # =========================================================================
1121
+ # Prepare Methods — Conversations
1122
+ # =========================================================================
1123
+
1124
+ def _prepare_list_conversations(
1125
+ self, user_id: Optional[str] = None, limit: int = 50,
1126
+ offset: int = 0, include_deleted: bool = False,
1127
+ from_date: Optional[str] = None, to_date: Optional[str] = None,
1128
+ ) -> tuple:
1129
+ params: Dict[str, Any] = {
1130
+ "tenant_id": self.tenant_id,
1131
+ "limit": limit,
1132
+ "offset": offset,
1133
+ "include_deleted": include_deleted,
1134
+ }
1135
+ if user_id:
1136
+ params["user_id"] = user_id
1137
+ if from_date:
1138
+ params["from_date"] = from_date
1139
+ if to_date:
1140
+ params["to_date"] = to_date
1141
+ return "conversations.list_conversations", params
1142
+
1143
+ def _prepare_get_conversation(self, conversation_id: str) -> tuple:
1144
+ return "conversations.get_conversation", {
1145
+ "tenant_id": self.tenant_id,
1146
+ "conversation_id": conversation_id,
1147
+ }
1148
+
1149
+ def _prepare_get_messages(
1150
+ self, conversation_id: str, limit: int = 100,
1151
+ offset: int = 0, include_deleted: bool = False,
1152
+ ) -> tuple:
1153
+ return "conversations.get_messages", {
1154
+ "tenant_id": self.tenant_id,
1155
+ "conversation_id": conversation_id,
1156
+ "limit": limit,
1157
+ "offset": offset,
1158
+ "include_deleted": include_deleted,
1159
+ }
1160
+
1161
+ def _prepare_create_message(
1162
+ self, conversation_id: str, message_id: str, role: str, content: str,
1163
+ user_id: Optional[str] = None, tokens_used: int = 0,
1164
+ context: Optional[Dict[str, Any]] = None,
1165
+ ) -> tuple:
1166
+ payload: Dict[str, Any] = {
1167
+ "tenant_id": self.tenant_id,
1168
+ "conversation_id": conversation_id,
1169
+ "message_id": message_id,
1170
+ "role": role,
1171
+ "content": content,
1172
+ "tokens_used": tokens_used,
1173
+ }
1174
+ if user_id:
1175
+ payload["user_id"] = user_id
1176
+ if context:
1177
+ payload["context"] = context
1178
+ return "conversations.create_message", payload
1179
+
1180
+ def _prepare_update_conversation(
1181
+ self, conversation_id: str,
1182
+ title: Optional[str] = None, user_id: Optional[str] = None,
1183
+ ) -> tuple:
1184
+ payload: Dict[str, Any] = {
1185
+ "tenant_id": self.tenant_id,
1186
+ "conversation_id": conversation_id,
1187
+ }
1188
+ if title is not None:
1189
+ payload["title"] = title
1190
+ if user_id is not None:
1191
+ payload["user_id"] = user_id
1192
+ return "conversations.update_conversation", payload
1193
+
1194
+ def _prepare_delete_conversation(self, conversation_id: str, hard_delete: bool = False) -> tuple:
1195
+ payload = {
1196
+ "tenant_id": self.tenant_id,
1197
+ "conversation_id": conversation_id,
1198
+ }
1199
+ if hard_delete:
1200
+ payload["hard_delete"] = True
1201
+ return "conversations.delete_conversation", payload
1202
+
1203
+ def _prepare_delete_message(self, conversation_id: str, message_id: str) -> tuple:
1204
+ return "conversations.delete_message", {
1205
+ "tenant_id": self.tenant_id,
1206
+ "conversation_id": conversation_id,
1207
+ "message_id": message_id,
1208
+ }
1209
+
1210
+ def _prepare_get_sync_stats(self) -> tuple:
1211
+ return "conversations.get_sync_stats", {"tenant_id": self.tenant_id}
1212
+
1213
+ def _prepare_get_message_events(
1214
+ self, conversation_id: str, message_id: Optional[str] = None,
1215
+ event_types: Optional[list] = None, limit: int = 100, offset: int = 0,
1216
+ ) -> tuple:
1217
+ params: Dict[str, Any] = {
1218
+ "tenant_id": self.tenant_id,
1219
+ "conversation_id": conversation_id,
1220
+ "limit": limit,
1221
+ "offset": offset,
1222
+ }
1223
+ if message_id:
1224
+ params["message_id"] = message_id
1225
+ if event_types:
1226
+ params["event_types"] = ",".join(event_types) if isinstance(event_types, list) else event_types
1227
+ return "conversations.get_message_events", params
1228
+
1229
+ def _prepare_get_tool_execution_stats(
1230
+ self, conversation_id: Optional[str] = None,
1231
+ from_date: Optional[str] = None, to_date: Optional[str] = None,
1232
+ ) -> tuple:
1233
+ params: Dict[str, Any] = {"tenant_id": self.tenant_id}
1234
+ if conversation_id:
1235
+ params["conversation_id"] = conversation_id
1236
+ if from_date:
1237
+ params["from_date"] = from_date
1238
+ if to_date:
1239
+ params["to_date"] = to_date
1240
+ return "conversations.get_tool_execution_stats", params
1241
+
1242
+ # =========================================================================
1243
+ # Prepare Methods — Users & MCP Servers
1244
+ # =========================================================================
1245
+
1246
+ def _prepare_register_user(
1247
+ self, user_id: str, display_name: Optional[str] = None,
1248
+ custom_instructions: Optional[str] = None,
1249
+ locale: Optional[str] = None,
1250
+ timezone: Optional[str] = None,
1251
+ user_role: Optional[str] = None,
1252
+ email: Optional[str] = None,
1253
+ registered_by: Optional[str] = None,
1254
+ ) -> tuple:
1255
+ params: Dict[str, Any] = {"tenant_id": self.tenant_id, "user_id": user_id}
1256
+ if display_name:
1257
+ params["display_name"] = display_name
1258
+ if custom_instructions:
1259
+ params["custom_instructions"] = custom_instructions
1260
+ if locale:
1261
+ params["locale"] = locale
1262
+ if timezone:
1263
+ params["timezone"] = timezone
1264
+ if user_role:
1265
+ params["user_role"] = user_role
1266
+ if email:
1267
+ params["email"] = email
1268
+ if registered_by:
1269
+ params["registered_by"] = registered_by
1270
+ return "users.register_user", params
1271
+
1272
+ def _prepare_invite_user(
1273
+ self,
1274
+ user_id: str,
1275
+ user_role: Optional[str] = None,
1276
+ invited_by: Optional[str] = None,
1277
+ ) -> tuple:
1278
+ """Build the invite_user request: create a Pending invite (reserves a seat)."""
1279
+ params: Dict[str, Any] = {"tenant_id": self.tenant_id, "user_id": user_id}
1280
+ if user_role:
1281
+ params["user_role"] = user_role
1282
+ if invited_by:
1283
+ params["invited_by"] = invited_by
1284
+ return "users.invite_user", params
1285
+
1286
+ def _prepare_revoke_invite(
1287
+ self,
1288
+ user_id: str,
1289
+ revoked_by: Optional[str] = None,
1290
+ ) -> tuple:
1291
+ """Build the revoke_invite request: cancel a Pending invite (frees its seat)."""
1292
+ params: Dict[str, Any] = {"tenant_id": self.tenant_id, "user_id": user_id}
1293
+ if revoked_by:
1294
+ params["revoked_by"] = revoked_by
1295
+ return "users.revoke_invite", params
1296
+
1297
+ def _prepare_resend_invite(
1298
+ self,
1299
+ user_id: str,
1300
+ resent_by: Optional[str] = None,
1301
+ ) -> tuple:
1302
+ """Build the resend_invite request: restart a Pending invite's expiry window."""
1303
+ params: Dict[str, Any] = {"tenant_id": self.tenant_id, "user_id": user_id}
1304
+ if resent_by:
1305
+ params["resent_by"] = resent_by
1306
+ return "users.resend_invite", params
1307
+
1308
+ def _prepare_list_invites(self) -> tuple:
1309
+ """Build the list_invites request: all Pending invites for this tenant."""
1310
+ return "users.list_invites", {"tenant_id": self.tenant_id}
1311
+
1312
+ def _prepare_get_member_audit_log(
1313
+ self, limit: int = 100, offset: int = 0,
1314
+ ) -> tuple:
1315
+ """Build the get_member_audit_log request (newest first, paginated).
1316
+
1317
+ Each returned entry's ``details`` field is a JSON string the caller
1318
+ must parse.
1319
+ """
1320
+ params: Dict[str, Any] = {
1321
+ "tenant_id": self.tenant_id,
1322
+ "limit": str(limit),
1323
+ "offset": str(offset),
1324
+ }
1325
+ return "users.get_member_audit_log", params
1326
+
1327
+ def _prepare_get_user(self, user_id: str) -> tuple:
1328
+ return "users.get_user", {"tenant_id": self.tenant_id, "user_id": user_id}
1329
+
1330
+ def _prepare_get_user_auth_status(self, user_id: str) -> tuple:
1331
+ return "users.get_user_auth_status", {
1332
+ "tenant_id": self.tenant_id,
1333
+ "user_id": user_id,
1334
+ }
1335
+
1336
+ def _prepare_add_user_mcp_server(
1337
+ self, user_id: str, server_name: str, endpoint_url: str,
1338
+ transport_type: str = "SSE", auth_type: str = "OAuth",
1339
+ oauth_client_id: Optional[str] = None,
1340
+ oauth_client_secret: Optional[str] = None,
1341
+ access_token: Optional[str] = None,
1342
+ refresh_token: Optional[str] = None,
1343
+ token_expires_in: int = 3600,
1344
+ api_key: Optional[str] = None,
1345
+ api_key_header: str = "Authorization",
1346
+ allowed_tools: Optional[list] = None,
1347
+ blocked_tools: Optional[list] = None,
1348
+ ) -> tuple:
1349
+ params: Dict[str, Any] = {
1350
+ "tenant_id": self.tenant_id,
1351
+ "user_id": user_id,
1352
+ "server_name": server_name,
1353
+ "endpoint_url": endpoint_url,
1354
+ "transport_type": transport_type,
1355
+ "auth_type": auth_type,
1356
+ }
1357
+ if oauth_client_id:
1358
+ params["oauth_client_id"] = oauth_client_id
1359
+ if oauth_client_secret:
1360
+ params["oauth_client_secret"] = oauth_client_secret
1361
+ if access_token:
1362
+ params["access_token"] = access_token
1363
+ if refresh_token:
1364
+ params["refresh_token"] = refresh_token
1365
+ if token_expires_in:
1366
+ params["token_expires_in"] = str(token_expires_in)
1367
+ if api_key:
1368
+ params["api_key"] = api_key
1369
+ if api_key_header:
1370
+ params["api_key_header"] = api_key_header
1371
+ if allowed_tools:
1372
+ params["allowed_tools"] = json.dumps(allowed_tools)
1373
+ if blocked_tools:
1374
+ params["blocked_tools"] = json.dumps(blocked_tools)
1375
+ return "users.add_user_mcp_server", params
1376
+
1377
+ def _prepare_get_user_mcp_servers(self, user_id: str) -> tuple:
1378
+ return "users.get_user_mcp_servers", {
1379
+ "tenant_id": self.tenant_id,
1380
+ "user_id": user_id,
1381
+ }
1382
+
1383
+ def _prepare_update_mcp_server_tokens(
1384
+ self, user_id: str, server_name: str, access_token: str,
1385
+ refresh_token: Optional[str] = None, token_expires_in: int = 3600,
1386
+ ) -> tuple:
1387
+ params: Dict[str, Any] = {
1388
+ "tenant_id": self.tenant_id,
1389
+ "user_id": user_id,
1390
+ "server_name": server_name,
1391
+ "access_token": access_token,
1392
+ "token_expires_in": str(token_expires_in),
1393
+ }
1394
+ if refresh_token:
1395
+ params["refresh_token"] = refresh_token
1396
+ return "users.update_mcp_server_tokens", params
1397
+
1398
+ def _prepare_remove_user_mcp_server(self, user_id: str, server_name: str) -> tuple:
1399
+ return "users.remove_user_mcp_server", {
1400
+ "tenant_id": self.tenant_id,
1401
+ "user_id": user_id,
1402
+ "server_name": server_name,
1403
+ }
1404
+
1405
+ def _prepare_list_users(
1406
+ self, status: Optional[str] = None, limit: int = 50,
1407
+ offset: int = 0, include_mcp_count: bool = True,
1408
+ ) -> tuple:
1409
+ params: Dict[str, Any] = {
1410
+ "tenant_id": self.tenant_id,
1411
+ "limit": str(limit),
1412
+ "offset": str(offset),
1413
+ "include_mcp_count": str(include_mcp_count).lower(),
1414
+ }
1415
+ if status:
1416
+ params["status"] = status
1417
+ return "users.list_users", params
1418
+
1419
+ def _prepare_update_user(
1420
+ self, user_id: str, display_name: Optional[str] = None,
1421
+ custom_instructions: Optional[str] = None,
1422
+ locale: Optional[str] = None,
1423
+ timezone: Optional[str] = None,
1424
+ user_role: Optional[str] = None,
1425
+ email: Optional[str] = None,
1426
+ job_title: Optional[str] = None,
1427
+ department: Optional[str] = None,
1428
+ about: Optional[str] = None,
1429
+ ) -> tuple:
1430
+ params: Dict[str, Any] = {
1431
+ "tenant_id": self.tenant_id,
1432
+ "user_id": user_id,
1433
+ }
1434
+ if display_name is not None:
1435
+ params["display_name"] = display_name
1436
+ if custom_instructions is not None:
1437
+ params["custom_instructions"] = custom_instructions
1438
+ if locale is not None:
1439
+ params["locale"] = locale
1440
+ if timezone is not None:
1441
+ params["timezone"] = timezone
1442
+ if user_role is not None:
1443
+ params["user_role"] = user_role
1444
+ if email is not None:
1445
+ params["email"] = email
1446
+ if job_title is not None:
1447
+ params["job_title"] = job_title
1448
+ if department is not None:
1449
+ params["department"] = department
1450
+ if about is not None:
1451
+ params["about"] = about
1452
+ return "users.update_user", params
1453
+
1454
+ def _prepare_deregister_user(self, user_id: str) -> tuple:
1455
+ return "users.deregister_user", {
1456
+ "tenant_id": self.tenant_id,
1457
+ "user_id": user_id,
1458
+ }
1459
+
1460
+ def _prepare_get_user_limit_status(self) -> tuple:
1461
+ return "users.get_user_limit_status", {"tenant_id": self.tenant_id}
1462
+
1463
+ def _prepare_suspend_user(self, user_id: str) -> tuple:
1464
+ return "users.suspend_user", {
1465
+ "tenant_id": self.tenant_id,
1466
+ "user_id": user_id,
1467
+ }
1468
+
1469
+ def _prepare_revoke_user(self, user_id: str) -> tuple:
1470
+ return "users.revoke_user", {
1471
+ "tenant_id": self.tenant_id,
1472
+ "user_id": user_id,
1473
+ }
1474
+
1475
+ # =========================================================================
1476
+ # Prepare Methods — Workflows
1477
+ # =========================================================================
1478
+
1479
+ def _prepare_create_workflow(
1480
+ self, workflow_name: str, graph_json: Optional[str] = None,
1481
+ description: str = "", default_model_id: Optional[str] = None,
1482
+ default_user_id: Optional[str] = None,
1483
+ error_strategy: str = "fail_fast", timeout_seconds: int = 600,
1484
+ ) -> tuple:
1485
+ payload: Dict[str, Any] = {
1486
+ "tenant_id": self.tenant_id,
1487
+ "workflow_name": workflow_name,
1488
+ "description": description,
1489
+ "error_strategy": error_strategy,
1490
+ "timeout_seconds": timeout_seconds,
1491
+ }
1492
+ if graph_json:
1493
+ payload["graph_json"] = graph_json
1494
+ if default_model_id:
1495
+ payload["default_model_id"] = default_model_id
1496
+ if default_user_id:
1497
+ payload["default_user_id"] = default_user_id
1498
+ return "workflows.create_workflow", payload
1499
+
1500
+ def _prepare_get_workflow(
1501
+ self, name: Optional[str] = None, workflow_name: Optional[str] = None,
1502
+ ) -> tuple:
1503
+ params: Dict[str, Any] = {"tenant_id": self.tenant_id}
1504
+ if name:
1505
+ params["name"] = name
1506
+ if workflow_name:
1507
+ params["workflow_name"] = workflow_name
1508
+ return "workflows.get_workflow", params
1509
+
1510
+ def _prepare_update_workflow(
1511
+ self, name: str, graph_json: Optional[str] = None,
1512
+ workflow_name: Optional[str] = None, description: Optional[str] = None,
1513
+ status: Optional[str] = None, default_model_id: Optional[str] = None,
1514
+ default_user_id: Optional[str] = None,
1515
+ error_strategy: Optional[str] = None,
1516
+ timeout_seconds: Optional[int] = None,
1517
+ max_node_executions: Optional[int] = None,
1518
+ max_retries: Optional[int] = None,
1519
+ ) -> tuple:
1520
+ payload: Dict[str, Any] = {
1521
+ "tenant_id": self.tenant_id,
1522
+ "name": name,
1523
+ }
1524
+ if graph_json is not None:
1525
+ payload["graph_json"] = graph_json
1526
+ if workflow_name is not None:
1527
+ payload["workflow_name"] = workflow_name
1528
+ if description is not None:
1529
+ payload["description"] = description
1530
+ if status is not None:
1531
+ payload["status"] = status
1532
+ if default_model_id is not None:
1533
+ payload["default_model_id"] = default_model_id
1534
+ if default_user_id is not None:
1535
+ payload["default_user_id"] = default_user_id
1536
+ if error_strategy is not None:
1537
+ payload["error_strategy"] = error_strategy
1538
+ if timeout_seconds is not None:
1539
+ payload["timeout_seconds"] = timeout_seconds
1540
+ if max_node_executions is not None:
1541
+ payload["max_node_executions"] = max_node_executions
1542
+ if max_retries is not None:
1543
+ payload["max_retries"] = max_retries
1544
+ return "workflows.update_workflow", payload
1545
+
1546
+ def _prepare_delete_workflow(self, name: str) -> tuple:
1547
+ return "workflows.delete_workflow", {
1548
+ "tenant_id": self.tenant_id,
1549
+ "name": name,
1550
+ }
1551
+
1552
+ def _prepare_list_workflows(
1553
+ self, status: Optional[str] = None,
1554
+ page: int = 0, page_size: int = 20,
1555
+ ) -> tuple:
1556
+ params: Dict[str, Any] = {
1557
+ "tenant_id": self.tenant_id,
1558
+ "page": str(page),
1559
+ "page_size": str(page_size),
1560
+ }
1561
+ if status:
1562
+ params["status"] = status
1563
+ return "workflows.list_workflows", params
1564
+
1565
+ def _prepare_execute_workflow(
1566
+ self, name: str, input_data: Optional[str] = None,
1567
+ user_id: Optional[str] = None,
1568
+ ) -> tuple:
1569
+ payload: Dict[str, Any] = {
1570
+ "tenant_id": self.tenant_id,
1571
+ "name": name,
1572
+ }
1573
+ if input_data:
1574
+ payload["input_data"] = input_data
1575
+ if user_id:
1576
+ payload["user_id"] = user_id
1577
+ return "workflows.execute_workflow", payload
1578
+
1579
+ def _prepare_execute_workflow_from_event(
1580
+ self,
1581
+ workflow_name: str,
1582
+ input_data: Dict[str, Any],
1583
+ user_id: str,
1584
+ trigger_id: str,
1585
+ ) -> tuple:
1586
+ return "workflows.execute_from_event", {
1587
+ "tenant_id": self.tenant_id,
1588
+ "workflow_name": workflow_name,
1589
+ "input_data": input_data,
1590
+ "user_id": user_id,
1591
+ "trigger_id": trigger_id,
1592
+ }
1593
+
1594
+ def _prepare_cancel_workflow_run(self, run_name: str) -> tuple:
1595
+ return "workflows.cancel_run", {
1596
+ "tenant_id": self.tenant_id,
1597
+ "run_name": run_name,
1598
+ }
1599
+
1600
+ def _prepare_get_workflow_run(self, run_name: str) -> tuple:
1601
+ return "workflows.get_run", {
1602
+ "tenant_id": self.tenant_id,
1603
+ "run_name": run_name,
1604
+ }
1605
+
1606
+ def _prepare_list_workflow_runs(
1607
+ self, workflow_name: Optional[str] = None,
1608
+ status: Optional[str] = None,
1609
+ page: int = 0, page_size: int = 20,
1610
+ ) -> tuple:
1611
+ params: Dict[str, Any] = {
1612
+ "tenant_id": self.tenant_id,
1613
+ "page": str(page),
1614
+ "page_size": str(page_size),
1615
+ }
1616
+ if workflow_name:
1617
+ params["workflow_name"] = workflow_name
1618
+ if status:
1619
+ params["status"] = status
1620
+ return "workflows.list_runs", params
1621
+
1622
+ def _prepare_get_workflow_audit_summary(
1623
+ self, workflow_id: str, window: str = "last_7_days",
1624
+ ) -> tuple:
1625
+ return "audit.get_workflow_audit_summary", {
1626
+ "tenant_id": self.tenant_id,
1627
+ "workflow_id": workflow_id,
1628
+ "window": window,
1629
+ }
1630
+
1631
+ def _prepare_set_workflow_schedule(
1632
+ self, name: str, cron_expression: str,
1633
+ timezone: str = "UTC", enabled: bool = True,
1634
+ default_input: Optional[str] = None,
1635
+ ) -> tuple:
1636
+ payload: Dict[str, Any] = {
1637
+ "tenant_id": self.tenant_id,
1638
+ "name": name,
1639
+ "cron_expression": cron_expression,
1640
+ "timezone": timezone,
1641
+ "enabled": enabled,
1642
+ }
1643
+ if default_input is not None:
1644
+ payload["default_input"] = default_input
1645
+ return "workflows.set_schedule", payload
1646
+
1647
+ def _prepare_validate_workflow_graph(self, graph_json: str) -> tuple:
1648
+ return "workflows.validate_graph", {
1649
+ "tenant_id": self.tenant_id,
1650
+ "graph_json": graph_json,
1651
+ }
1652
+
1653
+ def _prepare_test_workflow_node(
1654
+ self, node_json: str, input_text: str = "Test input",
1655
+ default_model_id: Optional[str] = None,
1656
+ default_user_id: Optional[str] = None,
1657
+ ) -> tuple:
1658
+ payload: Dict[str, Any] = {
1659
+ "tenant_id": self.tenant_id,
1660
+ "node_json": node_json,
1661
+ "input_text": input_text,
1662
+ }
1663
+ if default_model_id:
1664
+ payload["default_model_id"] = default_model_id
1665
+ if default_user_id:
1666
+ payload["default_user_id"] = default_user_id
1667
+ return "workflows.test_node", payload
1668
+
1669
+ def _prepare_resolve_workflow_tools(
1670
+ self, user_id: str, tool_directives: List[Dict[str, Any]],
1671
+ ) -> tuple:
1672
+ return "workflows.resolve_workflow_tools", {
1673
+ "tenant_id": self.tenant_id,
1674
+ "user_id": user_id,
1675
+ "tool_directives": json.dumps(tool_directives),
1676
+ }
1677
+
1678
+ def _prepare_run_workflow_node(
1679
+ self, name: str, node_id: str, input_text: str = "Test input",
1680
+ user_id: Optional[str] = None,
1681
+ ) -> tuple:
1682
+ payload: Dict[str, Any] = {
1683
+ "tenant_id": self.tenant_id,
1684
+ "name": name,
1685
+ "node_id": node_id,
1686
+ "input_text": input_text,
1687
+ }
1688
+ if user_id is not None:
1689
+ payload["user_id"] = user_id
1690
+ return "workflows.run_workflow_node", payload
1691
+
1692
+ # =========================================================================
1693
+ # Prepare Methods — Workflow Templates
1694
+ # =========================================================================
1695
+
1696
+ def _prepare_export_workflow(
1697
+ self, name: str, template_name: Optional[str] = None,
1698
+ category: str = "General", save_as_template: bool = False,
1699
+ is_public: bool = False,
1700
+ ) -> tuple:
1701
+ payload: Dict[str, Any] = {
1702
+ "tenant_id": self.tenant_id,
1703
+ "name": name,
1704
+ "category": category,
1705
+ "save_as_template": save_as_template,
1706
+ "is_public": is_public,
1707
+ }
1708
+ if template_name:
1709
+ payload["template_name"] = template_name
1710
+ return "workflows.export_workflow", payload
1711
+
1712
+ # `_prepare_list_templates`, `_prepare_get_template`, `_prepare_import_template`,
1713
+ # and `_prepare_update_template` removed in chunk 4 — use the marketplace
1714
+ # listing prepare helpers (`_prepare_list_listings`, `_prepare_get_listing`,
1715
+ # `_prepare_install_listing`, `_prepare_update_listing`) instead.
1716
+
1717
+ # =========================================================================
1718
+ # Prepare Methods — GDPR / Privacy
1719
+ # =========================================================================
1720
+
1721
+ def _prepare_export_user_data(self, user_id: str) -> tuple:
1722
+ return "gdpr.export_user_data", {
1723
+ "tenant_id": self.tenant_id,
1724
+ "user_id": user_id,
1725
+ }
1726
+
1727
+ def _prepare_erase_user_data(self, user_id: str) -> tuple:
1728
+ return "gdpr.erase_user_data", {
1729
+ "tenant_id": self.tenant_id,
1730
+ "user_id": user_id,
1731
+ }
1732
+
1733
+ def _prepare_rectify_user_data(self, user_id: str, updates: dict) -> tuple:
1734
+ import json
1735
+ return "gdpr.rectify_user_data", {
1736
+ "tenant_id": self.tenant_id,
1737
+ "user_id": user_id,
1738
+ "updates": json.dumps(updates) if isinstance(updates, dict) else updates,
1739
+ }
1740
+
1741
+ def _prepare_restrict_user_processing(self, user_id: str, restrict: bool = True) -> tuple:
1742
+ return "gdpr.restrict_user_processing", {
1743
+ "tenant_id": self.tenant_id,
1744
+ "user_id": user_id,
1745
+ "restrict": restrict,
1746
+ }
1747
+
1748
+ def _prepare_update_user_consent(self, user_id: str, consent_type: str, granted: bool = True) -> tuple:
1749
+ return "gdpr.update_user_consent", {
1750
+ "tenant_id": self.tenant_id,
1751
+ "user_id": user_id,
1752
+ "consent_type": consent_type,
1753
+ "granted": granted,
1754
+ }
1755
+
1756
+ # =========================================================================
1757
+ # Prepare Methods — Support (Tickets & Feedback)
1758
+ # =========================================================================
1759
+
1760
+ def _prepare_create_ticket(self, user_id: str, subject: str, description: str,
1761
+ category: Optional[str] = None,
1762
+ conversation_id: Optional[str] = None,
1763
+ environment: Optional[dict] = None,
1764
+ attachment_ids: Optional[List[str]] = None) -> tuple:
1765
+ """Returns (endpoint, payload)."""
1766
+ payload: Dict[str, Any] = {
1767
+ "tenant_id": self.tenant_id,
1768
+ "user_id": user_id,
1769
+ "subject": subject,
1770
+ "description": description,
1771
+ }
1772
+ if category is not None:
1773
+ payload["category"] = category
1774
+ if conversation_id is not None:
1775
+ payload["conversation_id"] = conversation_id
1776
+ if environment is not None:
1777
+ payload["environment"] = environment
1778
+ if attachment_ids:
1779
+ payload["attachment_ids"] = attachment_ids
1780
+ return "support.create_ticket", payload
1781
+
1782
+ def _prepare_submit_feedback(self, user_id: str, rating: Optional[int] = None,
1783
+ comment: Optional[str] = None,
1784
+ category: Optional[str] = None,
1785
+ conversation_id: Optional[str] = None,
1786
+ environment: Optional[dict] = None) -> tuple:
1787
+ """Returns (endpoint, payload)."""
1788
+ payload: Dict[str, Any] = {
1789
+ "tenant_id": self.tenant_id,
1790
+ "user_id": user_id,
1791
+ }
1792
+ if rating is not None:
1793
+ payload["rating"] = rating
1794
+ if comment is not None:
1795
+ payload["comment"] = comment
1796
+ if category is not None:
1797
+ payload["category"] = category
1798
+ if conversation_id is not None:
1799
+ payload["conversation_id"] = conversation_id
1800
+ if environment is not None:
1801
+ payload["environment"] = environment
1802
+ return "support.submit_feedback", payload
1803
+
1804
+ def _prepare_list_tickets(self, user_id: str, status: Optional[str] = None) -> tuple:
1805
+ """Returns (endpoint, payload)."""
1806
+ payload: Dict[str, Any] = {"tenant_id": self.tenant_id, "user_id": user_id}
1807
+ if status is not None:
1808
+ payload["status"] = status
1809
+ return "support.list_tickets", payload
1810
+
1811
+ def _prepare_get_ticket_thread(self, user_id: str, ticket_id: str) -> tuple:
1812
+ """Returns (endpoint, payload)."""
1813
+ return "support.get_ticket_thread", {
1814
+ "tenant_id": self.tenant_id,
1815
+ "user_id": user_id,
1816
+ "ticket_id": ticket_id,
1817
+ }
1818
+
1819
+ def _prepare_reply_to_ticket(self, user_id: str, ticket_id: str, message: str,
1820
+ attachment_ids: Optional[List[str]] = None) -> tuple:
1821
+ """Returns (endpoint, payload)."""
1822
+ payload: Dict[str, Any] = {
1823
+ "tenant_id": self.tenant_id,
1824
+ "user_id": user_id,
1825
+ "ticket_id": ticket_id,
1826
+ "message": message,
1827
+ }
1828
+ if attachment_ids:
1829
+ payload["attachment_ids"] = attachment_ids
1830
+ return "support.reply_to_ticket", payload
1831
+
1832
+ def _prepare_upload_ticket_attachment(
1833
+ self,
1834
+ user_id: str,
1835
+ file_name: str,
1836
+ file_data: bytes,
1837
+ content_type: Optional[str] = None,
1838
+ ) -> tuple:
1839
+ """Returns (endpoint, params, file_field, file_name, file_data, content_type).
1840
+
1841
+ Mirrors _prepare_upload_document: signs only the scalar form fields
1842
+ (tenant_id, user_id); the file bytes ride the multipart 'file' part,
1843
+ which Frappe's form_dict excludes from the signed body.
1844
+ """
1845
+ import mimetypes as _mt
1846
+
1847
+ if not user_id:
1848
+ raise ARConfigurationError("user_id is required for upload_ticket_attachment")
1849
+ if not file_name:
1850
+ raise ARConfigurationError("file_name is required for upload_ticket_attachment")
1851
+ if not file_data:
1852
+ raise ARConfigurationError("file_data is required for upload_ticket_attachment")
1853
+
1854
+ if not content_type:
1855
+ guessed, _ = _mt.guess_type(file_name)
1856
+ content_type = guessed or "application/octet-stream"
1857
+
1858
+ params: Dict[str, Any] = {
1859
+ "tenant_id": self.tenant_id,
1860
+ "user_id": user_id,
1861
+ }
1862
+ return (
1863
+ "support.upload_ticket_attachment",
1864
+ params,
1865
+ "file",
1866
+ file_name,
1867
+ file_data,
1868
+ content_type,
1869
+ )
1870
+
1871
+ def _prepare_get_tenant_privacy_config(self) -> tuple:
1872
+ return "gdpr.get_tenant_privacy_config", {
1873
+ "tenant_id": self.tenant_id,
1874
+ }
1875
+
1876
+ def _prepare_update_tenant_privacy_config(self, config: dict) -> tuple:
1877
+ import json
1878
+ return "gdpr.update_tenant_privacy_config", {
1879
+ "tenant_id": self.tenant_id,
1880
+ "config": json.dumps(config) if isinstance(config, dict) else config,
1881
+ }
1882
+
1883
+ # `_prepare_delete_template` removed in chunk 4 — use `_prepare_delete_listing` instead.
1884
+ # `_prepare_upload_template` removed in chunk 5 — use `_prepare_upload_listing_from_json`
1885
+ # (in publishing.py) which wraps the workflow template in a marketplace listing.
1886
+ # `_prepare_rate_template` removed in chunk 4 — use `_prepare_rate_listing` instead.
1887
+ # `_prepare_download_template` removed in chunk 5 — use `_prepare_download_listing_as_json`
1888
+ # instead.
1889
+
1890
+ # --- Heartbeat & Notifications ---
1891
+
1892
+ def _prepare_heartbeat(
1893
+ self,
1894
+ faco_version: Optional[str] = None,
1895
+ fac_version: Optional[str] = None,
1896
+ frappe_version: Optional[str] = None,
1897
+ erpnext_version: Optional[str] = None,
1898
+ python_version: Optional[str] = None,
1899
+ copilot_version: Optional[str] = None,
1900
+ ) -> tuple:
1901
+ payload: Dict[str, Any] = {"tenant_id": self.tenant_id}
1902
+ if faco_version:
1903
+ payload["faco_version"] = faco_version
1904
+ if fac_version:
1905
+ payload["fac_version"] = fac_version
1906
+ if frappe_version:
1907
+ payload["frappe_version"] = frappe_version
1908
+ if erpnext_version is not None:
1909
+ payload["erpnext_version"] = erpnext_version
1910
+ if python_version:
1911
+ payload["python_version"] = python_version
1912
+ if copilot_version:
1913
+ payload["copilot_version"] = copilot_version
1914
+ return "heartbeat.heartbeat", payload
1915
+
1916
+ def _prepare_get_notifications(self, user_id: str) -> tuple:
1917
+ return "notifications.get_notifications", {
1918
+ "tenant_id": self.tenant_id,
1919
+ "user_id": user_id,
1920
+ }
1921
+
1922
+ def _prepare_dismiss_notification(
1923
+ self, notification_id: str, user_id: str,
1924
+ ) -> tuple:
1925
+ return "notifications.dismiss_notification", {
1926
+ "tenant_id": self.tenant_id,
1927
+ "notification_id": notification_id,
1928
+ "user_id": user_id,
1929
+ }
1930
+
1931
+ # -------------------------------------------------------------------
1932
+ # Marketplace API prepares — route via marketplace_api_base
1933
+ # -------------------------------------------------------------------
1934
+
1935
+ def _prepare_list_listings(
1936
+ self,
1937
+ listing_type: Optional[str] = None,
1938
+ category: Optional[str] = None,
1939
+ search: Optional[str] = None,
1940
+ featured_only: bool = False,
1941
+ min_rating: Optional[float] = None,
1942
+ plan_tier: Optional[str] = None,
1943
+ sort_by: Optional[str] = None,
1944
+ page: int = 0,
1945
+ page_size: int = 20,
1946
+ user_id: Optional[str] = None,
1947
+ ) -> tuple:
1948
+ params: Dict[str, Any] = {
1949
+ "tenant_id": self.tenant_id,
1950
+ "page": page,
1951
+ "page_size": page_size,
1952
+ }
1953
+ if listing_type:
1954
+ params["listing_type"] = listing_type
1955
+ if category:
1956
+ params["category"] = category
1957
+ if search:
1958
+ params["search"] = search
1959
+ if featured_only:
1960
+ params["featured_only"] = "1"
1961
+ if min_rating is not None:
1962
+ params["min_rating"] = min_rating
1963
+ if plan_tier:
1964
+ params["plan_tier"] = plan_tier
1965
+ if sort_by:
1966
+ params["sort_by"] = sort_by
1967
+ if user_id:
1968
+ params["user_id"] = user_id
1969
+ return "listings.list_listings", params
1970
+
1971
+ def _prepare_get_listing(
1972
+ self, name: str, user_id: Optional[str] = None,
1973
+ include_source: bool = True,
1974
+ ) -> tuple:
1975
+ params: Dict[str, Any] = {
1976
+ "tenant_id": self.tenant_id,
1977
+ "name": name,
1978
+ "include_source": "1" if include_source else "0",
1979
+ }
1980
+ if user_id:
1981
+ params["user_id"] = user_id
1982
+ return "listings.get_listing", params
1983
+
1984
+ def _prepare_import_listing(
1985
+ self, user_id: str, name: str,
1986
+ new_title: Optional[str] = None,
1987
+ variables: Optional[str] = None,
1988
+ default_model_id: Optional[str] = None,
1989
+ ) -> tuple:
1990
+ payload: Dict[str, Any] = {
1991
+ "tenant_id": self.tenant_id,
1992
+ "user_id": user_id,
1993
+ "name": name,
1994
+ }
1995
+ if new_title:
1996
+ payload["new_title"] = new_title
1997
+ if variables:
1998
+ payload["variables"] = variables
1999
+ if default_model_id:
2000
+ payload["default_model_id"] = default_model_id
2001
+ return "listings.import_listing", payload
2002
+
2003
+ def _prepare_update_listing(
2004
+ self, name: str,
2005
+ title: Optional[str] = None,
2006
+ short_description: Optional[str] = None,
2007
+ description: Optional[str] = None,
2008
+ category: Optional[str] = None,
2009
+ tags: Optional[str] = None,
2010
+ icon: Optional[str] = None,
2011
+ is_public: Optional[bool] = None,
2012
+ is_published: Optional[bool] = None,
2013
+ plan_tier: Optional[str] = None,
2014
+ ) -> tuple:
2015
+ payload: Dict[str, Any] = {"tenant_id": self.tenant_id, "name": name}
2016
+ for k, v in (
2017
+ ("title", title), ("short_description", short_description),
2018
+ ("description", description), ("category", category),
2019
+ ("tags", tags), ("icon", icon),
2020
+ ):
2021
+ if v is not None:
2022
+ payload[k] = v
2023
+ if is_public is not None:
2024
+ payload["is_public"] = 1 if is_public else 0
2025
+ if is_published is not None:
2026
+ payload["is_published"] = 1 if is_published else 0
2027
+ if plan_tier is not None:
2028
+ payload["plan_tier"] = plan_tier
2029
+ return "publishing.update_listing", payload
2030
+
2031
+ def _prepare_delete_listing(self, name: str) -> tuple:
2032
+ return "publishing.delete_listing", {
2033
+ "tenant_id": self.tenant_id, "name": name,
2034
+ }
2035
+
2036
+ def _prepare_rate_listing(
2037
+ self, user_id: str, listing: str, rating: int, review: Optional[str] = None,
2038
+ ) -> tuple:
2039
+ payload: Dict[str, Any] = {
2040
+ "tenant_id": self.tenant_id,
2041
+ "user_id": user_id,
2042
+ "listing": listing,
2043
+ "rating": rating,
2044
+ }
2045
+ if review:
2046
+ payload["review"] = review
2047
+ return "ratings.rate_listing", payload
2048
+
2049
+ def _prepare_report_listing(
2050
+ self, user_id: str, listing: str, reason: str,
2051
+ details: Optional[str] = None,
2052
+ ) -> tuple:
2053
+ payload: Dict[str, Any] = {
2054
+ "tenant_id": self.tenant_id,
2055
+ "user_id": user_id,
2056
+ "listing": listing,
2057
+ "reason": reason,
2058
+ }
2059
+ if details:
2060
+ payload["details"] = details
2061
+ return "ratings.report_listing", payload
2062
+
2063
+ def _prepare_list_pending_reviews(
2064
+ self, page: int = 0, page_size: int = 20,
2065
+ ) -> tuple:
2066
+ return "moderation.list_pending_reviews", {
2067
+ "tenant_id": self.tenant_id,
2068
+ "page": page,
2069
+ "page_size": page_size,
2070
+ }
2071
+
2072
+ def _prepare_approve_listing(
2073
+ self, listing: str, notes: Optional[str] = None,
2074
+ ) -> tuple:
2075
+ payload: Dict[str, Any] = {"tenant_id": self.tenant_id, "listing": listing}
2076
+ if notes:
2077
+ payload["notes"] = notes
2078
+ return "moderation.approve_listing", payload
2079
+
2080
+ def _prepare_reject_listing(
2081
+ self, listing: str, notes: Optional[str] = None,
2082
+ ) -> tuple:
2083
+ payload: Dict[str, Any] = {"tenant_id": self.tenant_id, "listing": listing}
2084
+ if notes:
2085
+ payload["notes"] = notes
2086
+ return "moderation.reject_listing", payload
2087
+
2088
+ def _prepare_get_creator_stats(
2089
+ self, user_id: Optional[str] = None,
2090
+ ) -> tuple:
2091
+ params: Dict[str, Any] = {"tenant_id": self.tenant_id}
2092
+ if user_id:
2093
+ params["user_id"] = user_id
2094
+ return "creator.get_creator_stats", params
2095
+
2096
+ def _prepare_list_my_listings(
2097
+ self,
2098
+ user_id: Optional[str] = None,
2099
+ listing_type: Optional[str] = None,
2100
+ page: int = 0,
2101
+ page_size: int = 20,
2102
+ ) -> tuple:
2103
+ params: Dict[str, Any] = {
2104
+ "tenant_id": self.tenant_id,
2105
+ "page": page,
2106
+ "page_size": page_size,
2107
+ }
2108
+ if user_id:
2109
+ params["user_id"] = user_id
2110
+ if listing_type:
2111
+ params["listing_type"] = listing_type
2112
+ return "creator.list_my_listings", params
2113
+
2114
+ # -------------------------------------------------------------------
2115
+ # Marketplace publishing + downloads + version checks (chunk 5)
2116
+ # -------------------------------------------------------------------
2117
+
2118
+ def _prepare_publish_workflow(
2119
+ self,
2120
+ user_id: str,
2121
+ workflow_name: str,
2122
+ template_name: Optional[str] = None,
2123
+ category: str = "General",
2124
+ short_description: Optional[str] = None,
2125
+ description: Optional[str] = None,
2126
+ tags: Optional[str] = None,
2127
+ is_public: bool = False,
2128
+ plan_tier: Optional[str] = None,
2129
+ ) -> tuple:
2130
+ payload: Dict[str, Any] = {
2131
+ "tenant_id": self.tenant_id,
2132
+ "user_id": user_id,
2133
+ "workflow_name": workflow_name,
2134
+ "category": category,
2135
+ "is_public": 1 if is_public else 0,
2136
+ }
2137
+ for k, v in (
2138
+ ("template_name", template_name),
2139
+ ("short_description", short_description),
2140
+ ("description", description),
2141
+ ("tags", tags),
2142
+ ("plan_tier", plan_tier),
2143
+ ):
2144
+ if v is not None:
2145
+ payload[k] = v
2146
+ return "publishing.publish_workflow", payload
2147
+
2148
+ def _prepare_download_listing_as_json(self, name: str) -> tuple:
2149
+ return "listings.download_listing_as_json", {
2150
+ "tenant_id": self.tenant_id,
2151
+ "name": name,
2152
+ }
2153
+
2154
+ def _prepare_check_workflow_update(self, name: str) -> tuple:
2155
+ return "versions.check_workflow_update", {
2156
+ "tenant_id": self.tenant_id,
2157
+ "name": name,
2158
+ }
2159
+
2160
+ def _prepare_check_all_workflow_updates(self) -> tuple:
2161
+ return "versions.check_all_workflow_updates", {
2162
+ "tenant_id": self.tenant_id,
2163
+ }
2164
+
2165
+ # -------------------------------------------------------------------
2166
+ # Tenant Packs API prepares — route via marketplace_api_base
2167
+ #
2168
+ # These hit the *signed* endpoints in
2169
+ # ``assistant_runtime_marketplace.api.tenant_packs_signed`` (not the
2170
+ # whitelisted same-bench admin endpoints in ``api.tenant_packs``).
2171
+ # -------------------------------------------------------------------
2172
+
2173
+ def _prepare_list_packs(self) -> tuple:
2174
+ return "tenant_packs_signed.list_packs", {
2175
+ "tenant_id": self.tenant_id,
2176
+ }
2177
+
2178
+ def _prepare_get_pack_contents(self, pack_id: str) -> tuple:
2179
+ return "tenant_packs_signed.get_pack_contents", {
2180
+ "tenant_id": self.tenant_id,
2181
+ "pack_id": pack_id,
2182
+ }
2183
+
2184
+ def _prepare_set_industry(
2185
+ self,
2186
+ industry: Optional[str],
2187
+ auto_enable: bool = True,
2188
+ ) -> tuple:
2189
+ payload: Dict[str, Any] = {
2190
+ "tenant_id": self.tenant_id,
2191
+ "auto_enable": 1 if auto_enable else 0,
2192
+ }
2193
+ # An empty industry is a valid clear; send it as empty string.
2194
+ payload["industry"] = industry or ""
2195
+ return "tenant_packs_signed.set_industry", payload
2196
+
2197
+ def _prepare_set_pack_enabled(
2198
+ self,
2199
+ pack_id: str,
2200
+ enabled: bool,
2201
+ source: str = "User",
2202
+ ) -> tuple:
2203
+ return "tenant_packs_signed.set_pack_enabled", {
2204
+ "tenant_id": self.tenant_id,
2205
+ "pack_id": pack_id,
2206
+ "enabled": 1 if enabled else 0,
2207
+ "source": source,
2208
+ }
2209
+
2210
+ def _prepare_get_recommended_pack(
2211
+ self, user_id: Optional[str] = None,
2212
+ ) -> tuple:
2213
+ params: Dict[str, Any] = {"tenant_id": self.tenant_id}
2214
+ if user_id:
2215
+ params["user_id"] = user_id
2216
+ return "tenant_packs_signed.get_recommended_pack", params
2217
+
2218
+ def _prepare_dismiss_pack_recommendation(self, user_id: str) -> tuple:
2219
+ return "tenant_packs_signed.dismiss_pack_recommendation", {
2220
+ "tenant_id": self.tenant_id,
2221
+ "user_id": user_id,
2222
+ }
2223
+
2224
+ # -------------------------------------------------------------------
2225
+ # Pack acquisition + checkout (paid packs v2)
2226
+ # -------------------------------------------------------------------
2227
+
2228
+ def _prepare_enable_pack_as_free_grant(self, pack_id: str) -> tuple:
2229
+ return "tenant_packs_signed.enable_pack_as_free_grant", {
2230
+ "tenant_id": self.tenant_id,
2231
+ "pack_id": pack_id,
2232
+ }
2233
+
2234
+ def _prepare_grant_pack_as_admin(
2235
+ self, pack_id: str, granted_by_user: Optional[str] = None,
2236
+ ) -> tuple:
2237
+ payload: Dict[str, Any] = {
2238
+ "tenant_id": self.tenant_id,
2239
+ "pack_id": pack_id,
2240
+ }
2241
+ if granted_by_user:
2242
+ payload["granted_by_user"] = granted_by_user
2243
+ return "tenant_packs_signed.grant_pack_as_admin", payload
2244
+
2245
+ def _prepare_toggle_purchased_pack(
2246
+ self, pack_id: str, enabled: bool,
2247
+ ) -> tuple:
2248
+ return "tenant_packs_signed.toggle_purchased_pack", {
2249
+ "tenant_id": self.tenant_id,
2250
+ "pack_id": pack_id,
2251
+ "enabled": 1 if enabled else 0,
2252
+ }
2253
+
2254
+ def _prepare_list_pack_purchases(self) -> tuple:
2255
+ return "tenant_packs_signed.list_pack_purchases", {
2256
+ "tenant_id": self.tenant_id,
2257
+ }
2258
+
2259
+ def _prepare_initiate_pack_checkout(self, pack_id: str) -> tuple:
2260
+ return "billing.pack_checkout.initiate_pack_checkout", {
2261
+ "tenant_id": self.tenant_id,
2262
+ "pack_id": pack_id,
2263
+ }
2264
+
2265
+ def _prepare_verify_razorpay_pack_payment(
2266
+ self,
2267
+ razorpay_payment_id: str,
2268
+ razorpay_order_id: str,
2269
+ razorpay_signature: str,
2270
+ ) -> tuple:
2271
+ return "billing.pack_checkout.verify_razorpay_pack_payment", {
2272
+ "tenant_id": self.tenant_id,
2273
+ "razorpay_payment_id": razorpay_payment_id,
2274
+ "razorpay_order_id": razorpay_order_id,
2275
+ "razorpay_signature": razorpay_signature,
2276
+ }
2277
+