axonflow 0.4.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.
axonflow/client.py ADDED
@@ -0,0 +1,1612 @@
1
+ """AxonFlow SDK Main Client.
2
+
3
+ The primary interface for interacting with AxonFlow governance platform.
4
+ Supports both async and sync usage patterns.
5
+
6
+ Example:
7
+ >>> from axonflow import AxonFlow
8
+ >>>
9
+ >>> # Async usage
10
+ >>> async with AxonFlow(agent_url="...", client_id="...", client_secret="...") as client:
11
+ ... result = await client.execute_query("user-token", "What is AI?", "chat")
12
+ ... print(result.data)
13
+ >>>
14
+ >>> # Sync usage
15
+ >>> client = AxonFlow.sync(agent_url="...", client_id="...", client_secret="...")
16
+ >>> result = client.execute_query("user-token", "What is AI?", "chat")
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import hashlib
23
+ import re
24
+ from datetime import datetime
25
+ from typing import TYPE_CHECKING, Any
26
+
27
+ import httpx
28
+ import structlog
29
+ from cachetools import TTLCache
30
+ from tenacity import (
31
+ retry,
32
+ retry_if_exception_type,
33
+ stop_after_attempt,
34
+ wait_exponential,
35
+ )
36
+
37
+ from axonflow.exceptions import (
38
+ AuthenticationError,
39
+ AxonFlowError,
40
+ ConnectionError,
41
+ PolicyViolationError,
42
+ TimeoutError,
43
+ )
44
+ from axonflow.policies import (
45
+ CreateDynamicPolicyRequest,
46
+ CreatePolicyOverrideRequest,
47
+ CreateStaticPolicyRequest,
48
+ DynamicPolicy,
49
+ EffectivePoliciesOptions,
50
+ ListDynamicPoliciesOptions,
51
+ ListStaticPoliciesOptions,
52
+ PolicyCategory, # noqa: F401 - used in docstrings
53
+ PolicyOverride,
54
+ PolicyTier, # noqa: F401 - used in docstrings
55
+ PolicyVersion,
56
+ StaticPolicy,
57
+ TestPatternResult,
58
+ UpdateDynamicPolicyRequest,
59
+ UpdateStaticPolicyRequest,
60
+ )
61
+ from axonflow.types import (
62
+ AuditResult,
63
+ AxonFlowConfig,
64
+ CacheConfig,
65
+ ClientRequest,
66
+ ClientResponse,
67
+ ConnectorInstallRequest,
68
+ ConnectorMetadata,
69
+ ConnectorResponse,
70
+ Mode,
71
+ PlanExecutionResponse,
72
+ PlanResponse,
73
+ PlanStep,
74
+ PolicyApprovalResult,
75
+ RateLimitInfo,
76
+ RetryConfig,
77
+ TokenUsage,
78
+ )
79
+
80
+ if TYPE_CHECKING:
81
+ from types import TracebackType
82
+
83
+ logger = structlog.get_logger(__name__)
84
+
85
+
86
+ def _parse_datetime(value: str) -> datetime:
87
+ """Parse ISO format datetime string.
88
+
89
+ Python 3.9's fromisoformat() doesn't handle 'Z' suffix for UTC.
90
+ This helper replaces 'Z' with '+00:00' for compatibility.
91
+
92
+ Also handles nanosecond precision (9 digits) by truncating to microseconds (6 digits)
93
+ since Python's fromisoformat() only supports up to 6 fractional digits.
94
+ """
95
+ if value.endswith("Z"):
96
+ value = value[:-1] + "+00:00"
97
+
98
+ # Python's fromisoformat only supports up to 6 fractional digits (microseconds)
99
+ # Truncate nanoseconds (9 digits) to microseconds (6 digits) if needed
100
+ value = re.sub(r"(\.\d{6})\d+", r"\1", value)
101
+
102
+ return datetime.fromisoformat(value)
103
+
104
+
105
+ class AxonFlow:
106
+ """Main AxonFlow client for AI governance.
107
+
108
+ This client provides async-first API for interacting with AxonFlow Agent.
109
+ All methods are async by default, with sync wrappers available via `.sync()`.
110
+
111
+ Attributes:
112
+ config: Client configuration
113
+ """
114
+
115
+ __slots__ = ("_config", "_http_client", "_map_http_client", "_cache", "_logger")
116
+
117
+ def __init__(
118
+ self,
119
+ agent_url: str,
120
+ client_id: str,
121
+ client_secret: str,
122
+ *,
123
+ license_key: str | None = None,
124
+ mode: Mode | str = Mode.PRODUCTION,
125
+ debug: bool = False,
126
+ timeout: float = 60.0,
127
+ map_timeout: float = 120.0,
128
+ insecure_skip_verify: bool = False,
129
+ retry_config: RetryConfig | None = None,
130
+ cache_enabled: bool = True,
131
+ cache_ttl: float = 60.0,
132
+ cache_max_size: int = 1000,
133
+ ) -> None:
134
+ """Initialize AxonFlow client.
135
+
136
+ Args:
137
+ agent_url: AxonFlow Agent URL
138
+ client_id: Client ID for authentication
139
+ client_secret: Client secret for authentication
140
+ license_key: Optional license key for organization-level auth
141
+ mode: Operation mode (production or sandbox)
142
+ debug: Enable debug logging
143
+ timeout: Request timeout in seconds
144
+ map_timeout: Timeout for MAP operations in seconds (default: 120s)
145
+ MAP operations involve multiple LLM calls and need longer timeouts
146
+ insecure_skip_verify: Skip TLS verification (dev only)
147
+ retry_config: Retry configuration
148
+ cache_enabled: Enable response caching
149
+ cache_ttl: Cache TTL in seconds
150
+ cache_max_size: Maximum cache entries
151
+ """
152
+ if isinstance(mode, str):
153
+ mode = Mode(mode)
154
+
155
+ self._config = AxonFlowConfig(
156
+ agent_url=agent_url.rstrip("/"),
157
+ client_id=client_id,
158
+ client_secret=client_secret,
159
+ license_key=license_key,
160
+ mode=mode,
161
+ debug=debug,
162
+ timeout=timeout,
163
+ map_timeout=map_timeout,
164
+ insecure_skip_verify=insecure_skip_verify,
165
+ retry=retry_config or RetryConfig(),
166
+ cache=CacheConfig(enabled=cache_enabled, ttl=cache_ttl, max_size=cache_max_size),
167
+ )
168
+
169
+ # Configure SSL verification
170
+ verify_ssl: bool = not insecure_skip_verify
171
+
172
+ # Build headers
173
+ headers: dict[str, str] = {
174
+ "Content-Type": "application/json",
175
+ "X-Client-Secret": client_secret,
176
+ "X-Tenant-ID": client_id, # client_id is used as tenant ID for policy APIs
177
+ }
178
+ if license_key:
179
+ headers["X-License-Key"] = license_key
180
+
181
+ # Initialize HTTP client
182
+ self._http_client = httpx.AsyncClient(
183
+ timeout=httpx.Timeout(timeout),
184
+ verify=verify_ssl,
185
+ headers=headers,
186
+ )
187
+
188
+ # Initialize MAP HTTP client with longer timeout
189
+ self._map_http_client = httpx.AsyncClient(
190
+ timeout=httpx.Timeout(map_timeout),
191
+ verify=verify_ssl,
192
+ headers=headers,
193
+ )
194
+
195
+ # Initialize cache
196
+ self._cache: TTLCache[str, ClientResponse] | None = None
197
+ if cache_enabled:
198
+ self._cache = TTLCache(maxsize=cache_max_size, ttl=cache_ttl)
199
+
200
+ # Initialize logger
201
+ self._logger = structlog.get_logger(__name__).bind(
202
+ client_id=client_id,
203
+ mode=mode.value,
204
+ )
205
+
206
+ if debug:
207
+ self._logger.info(
208
+ "AxonFlow client initialized",
209
+ agent_url=agent_url,
210
+ )
211
+
212
+ @property
213
+ def config(self) -> AxonFlowConfig:
214
+ """Get client configuration."""
215
+ return self._config
216
+
217
+ async def __aenter__(self) -> AxonFlow:
218
+ """Async context manager entry."""
219
+ return self
220
+
221
+ async def __aexit__(
222
+ self,
223
+ exc_type: type[BaseException] | None,
224
+ exc_val: BaseException | None,
225
+ exc_tb: TracebackType | None,
226
+ ) -> None:
227
+ """Async context manager exit."""
228
+ await self.close()
229
+
230
+ async def close(self) -> None:
231
+ """Close the HTTP clients."""
232
+ await self._http_client.aclose()
233
+ await self._map_http_client.aclose()
234
+
235
+ @classmethod
236
+ def sync(
237
+ cls,
238
+ agent_url: str,
239
+ client_id: str,
240
+ client_secret: str,
241
+ **kwargs: Any,
242
+ ) -> SyncAxonFlow:
243
+ """Create a synchronous client wrapper.
244
+
245
+ Example:
246
+ >>> client = AxonFlow.sync(agent_url="...", client_id="...", client_secret="...")
247
+ >>> result = client.execute_query("token", "query", "chat")
248
+ """
249
+ return SyncAxonFlow(cls(agent_url, client_id, client_secret, **kwargs))
250
+
251
+ @classmethod
252
+ def sandbox(cls, api_key: str = "demo-key") -> AxonFlow:
253
+ """Create a sandbox client for testing.
254
+
255
+ Args:
256
+ api_key: Optional API key (defaults to demo-key)
257
+
258
+ Returns:
259
+ Configured AxonFlow client for sandbox environment
260
+ """
261
+ return cls(
262
+ agent_url="https://staging-eu.getaxonflow.com",
263
+ client_id=api_key,
264
+ client_secret=api_key,
265
+ mode=Mode.SANDBOX,
266
+ debug=True,
267
+ )
268
+
269
+ def _get_cache_key(self, request_type: str, query: str, user_token: str) -> str:
270
+ """Generate cache key for a request."""
271
+ key = f"{request_type}:{query}:{user_token}"
272
+ return hashlib.sha256(key.encode()).hexdigest()[:32]
273
+
274
+ async def _request(
275
+ self,
276
+ method: str,
277
+ path: str,
278
+ *,
279
+ json_data: dict[str, Any] | None = None,
280
+ ) -> dict[str, Any]:
281
+ """Make HTTP request to Agent."""
282
+ url = f"{self._config.agent_url}{path}"
283
+
284
+ try:
285
+ if self._config.retry.enabled:
286
+ response = await self._request_with_retry(method, url, json_data)
287
+ else:
288
+ response = await self._http_client.request(method, url, json=json_data)
289
+
290
+ response.raise_for_status()
291
+ # Handle 204 No Content (e.g., DELETE responses)
292
+ if response.status_code == 204: # noqa: PLR2004
293
+ return None # type: ignore[return-value]
294
+ return response.json() # type: ignore[no-any-return]
295
+
296
+ except httpx.ConnectError as e:
297
+ msg = f"Failed to connect to AxonFlow Agent: {e}"
298
+ raise ConnectionError(msg) from e
299
+ except httpx.TimeoutException as e:
300
+ msg = f"Request timed out: {e}"
301
+ raise TimeoutError(msg) from e
302
+ except httpx.HTTPStatusError as e:
303
+ if e.response.status_code == 401: # noqa: PLR2004
304
+ msg = "Invalid credentials"
305
+ raise AuthenticationError(msg) from e
306
+ if e.response.status_code == 403: # noqa: PLR2004
307
+ body = e.response.json()
308
+ # Extract policy from policy_info if available
309
+ policy = body.get("policy")
310
+ if not policy:
311
+ policy_info = body.get("policy_info")
312
+ if policy_info and policy_info.get("policies_evaluated"):
313
+ policy = policy_info["policies_evaluated"][0]
314
+ raise PolicyViolationError(
315
+ body.get("block_reason") or body.get("message", "Request blocked by policy"),
316
+ policy=policy,
317
+ block_reason=body.get("block_reason"),
318
+ ) from e
319
+ msg = f"HTTP {e.response.status_code}: {e.response.text}"
320
+ raise AxonFlowError(msg) from e
321
+
322
+ async def _request_with_retry(
323
+ self,
324
+ method: str,
325
+ url: str,
326
+ json_data: dict[str, Any] | None,
327
+ ) -> httpx.Response:
328
+ """Make request with retry logic."""
329
+
330
+ @retry(
331
+ stop=stop_after_attempt(self._config.retry.max_attempts),
332
+ wait=wait_exponential(
333
+ multiplier=self._config.retry.initial_delay,
334
+ max=self._config.retry.max_delay,
335
+ exp_base=self._config.retry.exponential_base,
336
+ ),
337
+ retry=retry_if_exception_type((httpx.ConnectError, httpx.TimeoutException)),
338
+ reraise=True,
339
+ )
340
+ async def _do_request() -> httpx.Response:
341
+ return await self._http_client.request(method, url, json=json_data)
342
+
343
+ return await _do_request()
344
+
345
+ async def _map_request(
346
+ self,
347
+ method: str,
348
+ path: str,
349
+ *,
350
+ json_data: dict[str, Any] | None = None,
351
+ ) -> dict[str, Any]:
352
+ """Make HTTP request to Agent using MAP timeout.
353
+
354
+ This uses the longer map_timeout for MAP operations that involve
355
+ multiple LLM calls and can take 30-60+ seconds.
356
+ """
357
+ url = f"{self._config.agent_url}{path}"
358
+
359
+ try:
360
+ if self._config.debug:
361
+ self._logger.debug(
362
+ "MAP request",
363
+ url=url,
364
+ timeout=self._config.map_timeout,
365
+ )
366
+
367
+ response = await self._map_http_client.request(method, url, json=json_data)
368
+ response.raise_for_status()
369
+ return response.json() # type: ignore[no-any-return]
370
+
371
+ except httpx.ConnectError as e:
372
+ msg = f"Failed to connect to AxonFlow Agent: {e}"
373
+ raise ConnectionError(msg) from e
374
+ except httpx.TimeoutException as e:
375
+ msg = f"MAP request timed out after {self._config.map_timeout}s: {e}"
376
+ raise TimeoutError(msg) from e
377
+ except httpx.HTTPStatusError as e:
378
+ if e.response.status_code == 401: # noqa: PLR2004
379
+ msg = "Invalid credentials"
380
+ raise AuthenticationError(msg) from e
381
+ if e.response.status_code == 403: # noqa: PLR2004
382
+ body = e.response.json()
383
+ policy = body.get("policy")
384
+ if not policy:
385
+ policy_info = body.get("policy_info")
386
+ if policy_info and policy_info.get("policies_evaluated"):
387
+ policy = policy_info["policies_evaluated"][0]
388
+ raise PolicyViolationError(
389
+ body.get("block_reason") or body.get("message", "Request blocked by policy"),
390
+ policy=policy,
391
+ block_reason=body.get("block_reason"),
392
+ ) from e
393
+ msg = f"HTTP {e.response.status_code}: {e.response.text}"
394
+ raise AxonFlowError(msg) from e
395
+
396
+ async def health_check(self) -> bool:
397
+ """Check if AxonFlow Agent is healthy.
398
+
399
+ Returns:
400
+ True if agent is healthy, False otherwise
401
+ """
402
+ try:
403
+ response = await self._request("GET", "/health")
404
+ return response.get("status") == "healthy"
405
+ except AxonFlowError:
406
+ return False
407
+
408
+ async def execute_query(
409
+ self,
410
+ user_token: str,
411
+ query: str,
412
+ request_type: str,
413
+ context: dict[str, Any] | None = None,
414
+ ) -> ClientResponse:
415
+ """Execute a query through AxonFlow with policy enforcement.
416
+
417
+ Args:
418
+ user_token: User authentication token
419
+ query: The query or prompt
420
+ request_type: Type of request (chat, sql, mcp-query, multi-agent-plan)
421
+ context: Optional additional context
422
+
423
+ Returns:
424
+ ClientResponse with results or error
425
+
426
+ Raises:
427
+ PolicyViolationError: If request is blocked by policy
428
+ AuthenticationError: If credentials are invalid
429
+ TimeoutError: If request times out
430
+ """
431
+ # Check cache
432
+ if self._cache is not None:
433
+ cache_key = self._get_cache_key(request_type, query, user_token)
434
+ if cache_key in self._cache:
435
+ if self._config.debug:
436
+ self._logger.debug("Cache hit", query=query[:50])
437
+ cached_result: ClientResponse = self._cache[cache_key]
438
+ return cached_result
439
+ else:
440
+ cache_key = ""
441
+
442
+ request = ClientRequest(
443
+ query=query,
444
+ user_token=user_token,
445
+ client_id=self._config.client_id,
446
+ request_type=request_type,
447
+ context=context or {},
448
+ )
449
+
450
+ if self._config.debug:
451
+ self._logger.debug(
452
+ "Executing query",
453
+ request_type=request_type,
454
+ query=query[:50] if query else "",
455
+ )
456
+
457
+ response_data = await self._request(
458
+ "POST",
459
+ "/api/request",
460
+ json_data=request.model_dump(),
461
+ )
462
+
463
+ response = ClientResponse.model_validate(response_data)
464
+
465
+ # Check for policy violation
466
+ if response.blocked:
467
+ # Extract policy name from policy_info if available
468
+ policy = None
469
+ if response.policy_info and response.policy_info.policies_evaluated:
470
+ policy = response.policy_info.policies_evaluated[0]
471
+ raise PolicyViolationError(
472
+ response.block_reason or "Request blocked by policy",
473
+ policy=policy,
474
+ block_reason=response.block_reason,
475
+ )
476
+
477
+ # Cache successful responses
478
+ if self._cache is not None and response.success and cache_key:
479
+ self._cache[cache_key] = response
480
+
481
+ return response
482
+
483
+ async def list_connectors(self) -> list[ConnectorMetadata]:
484
+ """List all available MCP connectors.
485
+
486
+ Returns:
487
+ List of connector metadata
488
+ """
489
+ response = await self._request("GET", "/api/connectors")
490
+ return [ConnectorMetadata.model_validate(c) for c in response]
491
+
492
+ async def install_connector(self, request: ConnectorInstallRequest) -> None:
493
+ """Install an MCP connector.
494
+
495
+ Args:
496
+ request: Connector installation request
497
+ """
498
+ await self._request(
499
+ "POST",
500
+ "/api/connectors/install",
501
+ json_data=request.model_dump(),
502
+ )
503
+
504
+ if self._config.debug:
505
+ self._logger.info("Connector installed", name=request.name)
506
+
507
+ async def query_connector(
508
+ self,
509
+ user_token: str,
510
+ connector_name: str,
511
+ operation: str,
512
+ params: dict[str, Any] | None = None,
513
+ ) -> ConnectorResponse:
514
+ """Query an MCP connector directly.
515
+
516
+ Args:
517
+ user_token: User authentication token
518
+ connector_name: Name of the connector
519
+ operation: Operation to perform
520
+ params: Operation parameters
521
+
522
+ Returns:
523
+ ConnectorResponse with results
524
+ """
525
+ request_data: dict[str, Any] = {
526
+ "client_id": self._config.client_id,
527
+ "user_token": user_token,
528
+ "connector": connector_name,
529
+ "operation": operation,
530
+ "parameters": params or {},
531
+ }
532
+
533
+ if self._config.license_key:
534
+ request_data["license_key"] = self._config.license_key
535
+
536
+ response = await self._request(
537
+ "POST",
538
+ "/mcp/resources/query",
539
+ json_data=request_data,
540
+ )
541
+
542
+ return ConnectorResponse.model_validate(response)
543
+
544
+ async def generate_plan(
545
+ self,
546
+ query: str,
547
+ domain: str | None = None,
548
+ user_token: str | None = None,
549
+ ) -> PlanResponse:
550
+ """Generate a multi-agent execution plan.
551
+
552
+ Args:
553
+ query: Natural language query describing the task
554
+ domain: Optional domain hint (travel, healthcare, etc.)
555
+ user_token: Optional user token for authentication (defaults to client_id)
556
+
557
+ Returns:
558
+ PlanResponse with generated plan
559
+
560
+ Note:
561
+ This uses map_timeout (default 120s) as MAP operations involve
562
+ multiple LLM calls and can take 30-60+ seconds.
563
+ """
564
+ context = {"domain": domain} if domain else {}
565
+
566
+ request = ClientRequest(
567
+ query=query,
568
+ user_token=user_token or self._config.client_id,
569
+ client_id=self._config.client_id,
570
+ request_type="multi-agent-plan",
571
+ context=context,
572
+ )
573
+
574
+ if self._config.debug:
575
+ self._logger.debug(
576
+ "Generating plan",
577
+ query=query[:50] if query else "",
578
+ domain=domain,
579
+ timeout=self._config.map_timeout,
580
+ )
581
+
582
+ # Use MAP request with longer timeout
583
+ response_data = await self._map_request(
584
+ "POST",
585
+ "/api/request",
586
+ json_data=request.model_dump(),
587
+ )
588
+
589
+ response = ClientResponse.model_validate(response_data)
590
+
591
+ if not response.success:
592
+ msg = f"Plan generation failed: {response.error}"
593
+ raise AxonFlowError(msg)
594
+
595
+ # Extract steps from response data
596
+ steps: list[PlanStep] = []
597
+ if response.data and isinstance(response.data, dict):
598
+ steps_data = response.data.get("steps", [])
599
+ steps = [PlanStep.model_validate(s) for s in steps_data]
600
+ # Also check for plan_id in data
601
+ if not response.plan_id and response.data.get("plan_id"):
602
+ response = ClientResponse.model_validate(
603
+ {
604
+ **response_data,
605
+ "plan_id": response.data.get("plan_id"),
606
+ }
607
+ )
608
+
609
+ plan_id = response.plan_id or (
610
+ response.data.get("plan_id", "") if isinstance(response.data, dict) else ""
611
+ )
612
+ return PlanResponse(
613
+ plan_id=plan_id,
614
+ steps=steps,
615
+ domain=response.data.get("domain", domain or "generic")
616
+ if response.data and isinstance(response.data, dict)
617
+ else (domain or "generic"),
618
+ complexity=response.data.get("complexity", 0)
619
+ if response.data and isinstance(response.data, dict)
620
+ else 0,
621
+ parallel=response.data.get("parallel", False)
622
+ if response.data and isinstance(response.data, dict)
623
+ else False,
624
+ metadata=response.metadata,
625
+ )
626
+
627
+ async def execute_plan(
628
+ self,
629
+ plan_id: str,
630
+ user_token: str | None = None,
631
+ ) -> PlanExecutionResponse:
632
+ """Execute a previously generated plan.
633
+
634
+ Args:
635
+ plan_id: ID of the plan to execute
636
+ user_token: Optional user token for authentication (defaults to client_id)
637
+
638
+ Returns:
639
+ PlanExecutionResponse with results
640
+
641
+ Note:
642
+ This uses map_timeout (default 120s) as plan execution involves
643
+ multiple LLM calls and can take 30-60+ seconds.
644
+ """
645
+ request = ClientRequest(
646
+ query="",
647
+ user_token=user_token or self._config.client_id,
648
+ client_id=self._config.client_id,
649
+ request_type="execute-plan",
650
+ context={"plan_id": plan_id},
651
+ )
652
+
653
+ if self._config.debug:
654
+ self._logger.debug(
655
+ "Executing plan",
656
+ plan_id=plan_id,
657
+ timeout=self._config.map_timeout,
658
+ )
659
+
660
+ # Use MAP request with longer timeout
661
+ response_data = await self._map_request(
662
+ "POST",
663
+ "/api/request",
664
+ json_data=request.model_dump(),
665
+ )
666
+
667
+ response = ClientResponse.model_validate(response_data)
668
+
669
+ return PlanExecutionResponse(
670
+ plan_id=plan_id,
671
+ status="completed" if response.success else "failed",
672
+ result=response.result,
673
+ step_results=response.metadata.get("step_results", {}),
674
+ error=response.error,
675
+ duration=response.metadata.get("duration"),
676
+ )
677
+
678
+ async def get_plan_status(self, plan_id: str) -> PlanExecutionResponse:
679
+ """Get status of a running or completed plan.
680
+
681
+ Args:
682
+ plan_id: ID of the plan
683
+
684
+ Returns:
685
+ PlanExecutionResponse with current status
686
+ """
687
+ response = await self._request("GET", f"/api/plans/{plan_id}")
688
+ return PlanExecutionResponse.model_validate(response)
689
+
690
+ # =========================================================================
691
+ # Gateway Mode Methods
692
+ # =========================================================================
693
+
694
+ async def get_policy_approved_context(
695
+ self,
696
+ user_token: str,
697
+ query: str,
698
+ data_sources: list[str] | None = None,
699
+ context: dict[str, Any] | None = None,
700
+ ) -> PolicyApprovalResult:
701
+ """Perform policy pre-check before making LLM call.
702
+
703
+ This is the first step in Gateway Mode. Call this before making your
704
+ LLM call to ensure policy compliance.
705
+
706
+ Args:
707
+ user_token: JWT token for the user making the request
708
+ query: The query/prompt that will be sent to the LLM
709
+ data_sources: Optional list of MCP connectors to fetch data from
710
+ context: Optional additional context for policy evaluation
711
+
712
+ Returns:
713
+ PolicyApprovalResult with context ID and approved data
714
+
715
+ Raises:
716
+ AuthenticationError: If user token is invalid
717
+ ConnectionError: If unable to reach AxonFlow Agent
718
+ TimeoutError: If request times out
719
+
720
+ Example:
721
+ >>> result = await client.get_policy_approved_context(
722
+ ... user_token="user-jwt",
723
+ ... query="Find patients with diabetes",
724
+ ... data_sources=["postgres"]
725
+ ... )
726
+ >>> if not result.approved:
727
+ ... raise PolicyViolationError(result.block_reason)
728
+ """
729
+ request_body = {
730
+ "user_token": user_token,
731
+ "client_id": self._config.client_id,
732
+ "query": query,
733
+ "data_sources": data_sources or [],
734
+ "context": context or {},
735
+ }
736
+
737
+ if self._config.debug:
738
+ self._logger.debug(
739
+ "Gateway pre-check request",
740
+ query=query[:50] if query else "",
741
+ data_sources=data_sources,
742
+ )
743
+
744
+ response = await self._request(
745
+ "POST",
746
+ "/api/policy/pre-check",
747
+ json_data=request_body,
748
+ )
749
+
750
+ if self._config.debug:
751
+ self._logger.debug(
752
+ "Gateway pre-check complete",
753
+ context_id=response.get("context_id"),
754
+ approved=response.get("approved"),
755
+ )
756
+
757
+ rate_limit = None
758
+ if response.get("rate_limit"):
759
+ rate_limit = RateLimitInfo(
760
+ limit=response["rate_limit"]["limit"],
761
+ remaining=response["rate_limit"]["remaining"],
762
+ reset_at=_parse_datetime(response["rate_limit"]["reset_at"]),
763
+ )
764
+
765
+ return PolicyApprovalResult(
766
+ context_id=response["context_id"],
767
+ approved=response["approved"],
768
+ approved_data=response.get("approved_data", {}),
769
+ policies=response.get("policies", []),
770
+ rate_limit_info=rate_limit,
771
+ expires_at=_parse_datetime(response["expires_at"]),
772
+ block_reason=response.get("block_reason"),
773
+ )
774
+
775
+ async def audit_llm_call(
776
+ self,
777
+ context_id: str,
778
+ response_summary: str,
779
+ provider: str,
780
+ model: str,
781
+ token_usage: TokenUsage,
782
+ latency_ms: int,
783
+ metadata: dict[str, Any] | None = None,
784
+ ) -> AuditResult:
785
+ """Report LLM call details for audit logging.
786
+
787
+ This is the second step in Gateway Mode. Call this after making your
788
+ LLM call to record it in the audit trail.
789
+
790
+ Args:
791
+ context_id: Context ID from get_policy_approved_context()
792
+ response_summary: Brief summary of the LLM response (not full response)
793
+ provider: LLM provider name (openai, anthropic, bedrock, ollama)
794
+ model: Model name (gpt-4, claude-3-sonnet, etc.)
795
+ token_usage: Token counts from the LLM response
796
+ latency_ms: Time taken for the LLM call in milliseconds
797
+ metadata: Optional additional metadata to log
798
+
799
+ Returns:
800
+ AuditResult confirming the audit was recorded
801
+
802
+ Raises:
803
+ AxonFlowError: If audit recording fails
804
+
805
+ Example:
806
+ >>> result = await client.audit_llm_call(
807
+ ... context_id=ctx.context_id,
808
+ ... response_summary="Found 5 patients with recent lab results",
809
+ ... provider="openai",
810
+ ... model="gpt-4",
811
+ ... token_usage=TokenUsage(
812
+ ... prompt_tokens=100,
813
+ ... completion_tokens=50,
814
+ ... total_tokens=150
815
+ ... ),
816
+ ... latency_ms=250
817
+ ... )
818
+ """
819
+ request_body = {
820
+ "context_id": context_id,
821
+ "client_id": self._config.client_id,
822
+ "response_summary": response_summary,
823
+ "provider": provider,
824
+ "model": model,
825
+ "token_usage": {
826
+ "prompt_tokens": token_usage.prompt_tokens,
827
+ "completion_tokens": token_usage.completion_tokens,
828
+ "total_tokens": token_usage.total_tokens,
829
+ },
830
+ "latency_ms": latency_ms,
831
+ "metadata": metadata or {},
832
+ }
833
+
834
+ if self._config.debug:
835
+ self._logger.debug(
836
+ "Gateway audit request",
837
+ context_id=context_id,
838
+ provider=provider,
839
+ model=model,
840
+ tokens=token_usage.total_tokens,
841
+ )
842
+
843
+ response = await self._request(
844
+ "POST",
845
+ "/api/audit/llm-call",
846
+ json_data=request_body,
847
+ )
848
+
849
+ if self._config.debug:
850
+ self._logger.debug(
851
+ "Gateway audit complete",
852
+ audit_id=response.get("audit_id"),
853
+ )
854
+
855
+ return AuditResult(
856
+ success=response["success"],
857
+ audit_id=response["audit_id"],
858
+ )
859
+
860
+ # =========================================================================
861
+ # Policy CRUD Methods - Static Policies
862
+ # =========================================================================
863
+
864
+ async def list_static_policies(
865
+ self,
866
+ options: ListStaticPoliciesOptions | None = None,
867
+ ) -> list[StaticPolicy]:
868
+ """List all static policies with optional filtering.
869
+
870
+ Args:
871
+ options: Filtering and pagination options
872
+
873
+ Returns:
874
+ List of static policies
875
+
876
+ Example:
877
+ >>> policies = await client.list_static_policies(
878
+ ... ListStaticPoliciesOptions(category=PolicyCategory.SECURITY_SQLI)
879
+ ... )
880
+ """
881
+ params: list[str] = []
882
+ if options:
883
+ if options.category:
884
+ params.append(f"category={options.category.value}")
885
+ if options.tier:
886
+ params.append(f"tier={options.tier.value}")
887
+ if options.enabled is not None:
888
+ params.append(f"enabled={str(options.enabled).lower()}")
889
+ if options.limit:
890
+ params.append(f"limit={options.limit}")
891
+ if options.offset:
892
+ params.append(f"offset={options.offset}")
893
+ if options.sort_by:
894
+ params.append(f"sort_by={options.sort_by}")
895
+ if options.sort_order:
896
+ params.append(f"sort_order={options.sort_order}")
897
+ if options.search:
898
+ params.append(f"search={options.search}")
899
+
900
+ path = "/api/v1/static-policies"
901
+ if params:
902
+ path = f"{path}?{'&'.join(params)}"
903
+
904
+ if self._config.debug:
905
+ self._logger.debug("Listing static policies", path=path)
906
+
907
+ response = await self._request("GET", path)
908
+ # Backend returns { policies: [], pagination: {} }, extract the policies array
909
+ policies = response.get("policies", []) if isinstance(response, dict) else response
910
+ return [StaticPolicy.model_validate(p) for p in policies]
911
+
912
+ async def get_static_policy(self, policy_id: str) -> StaticPolicy:
913
+ """Get a specific static policy by ID.
914
+
915
+ Args:
916
+ policy_id: Policy ID
917
+
918
+ Returns:
919
+ The static policy
920
+ """
921
+ if self._config.debug:
922
+ self._logger.debug("Getting static policy", policy_id=policy_id)
923
+
924
+ response = await self._request("GET", f"/api/v1/static-policies/{policy_id}")
925
+ return StaticPolicy.model_validate(response)
926
+
927
+ async def create_static_policy(
928
+ self,
929
+ request: CreateStaticPolicyRequest,
930
+ ) -> StaticPolicy:
931
+ """Create a new static policy.
932
+
933
+ Args:
934
+ request: Policy creation request
935
+
936
+ Returns:
937
+ The created policy
938
+
939
+ Example:
940
+ >>> policy = await client.create_static_policy(
941
+ ... CreateStaticPolicyRequest(
942
+ ... name="Block Credit Cards",
943
+ ... category=PolicyCategory.PII_GLOBAL,
944
+ ... pattern=r"\\b(?:\\d{4}[- ]?){3}\\d{4}\\b",
945
+ ... severity=8
946
+ ... )
947
+ ... )
948
+ """
949
+ if self._config.debug:
950
+ self._logger.debug("Creating static policy", name=request.name)
951
+
952
+ response = await self._request(
953
+ "POST",
954
+ "/api/v1/static-policies",
955
+ json_data=request.model_dump(exclude_none=True, by_alias=True),
956
+ )
957
+ return StaticPolicy.model_validate(response)
958
+
959
+ async def update_static_policy(
960
+ self,
961
+ policy_id: str,
962
+ request: UpdateStaticPolicyRequest,
963
+ ) -> StaticPolicy:
964
+ """Update an existing static policy.
965
+
966
+ Args:
967
+ policy_id: Policy ID
968
+ request: Fields to update
969
+
970
+ Returns:
971
+ The updated policy
972
+ """
973
+ if self._config.debug:
974
+ self._logger.debug("Updating static policy", policy_id=policy_id)
975
+
976
+ response = await self._request(
977
+ "PUT",
978
+ f"/api/v1/static-policies/{policy_id}",
979
+ json_data=request.model_dump(exclude_none=True, by_alias=True),
980
+ )
981
+ return StaticPolicy.model_validate(response)
982
+
983
+ async def delete_static_policy(self, policy_id: str) -> None:
984
+ """Delete a static policy.
985
+
986
+ Args:
987
+ policy_id: Policy ID
988
+ """
989
+ if self._config.debug:
990
+ self._logger.debug("Deleting static policy", policy_id=policy_id)
991
+
992
+ await self._request("DELETE", f"/api/v1/static-policies/{policy_id}")
993
+
994
+ async def toggle_static_policy(
995
+ self,
996
+ policy_id: str,
997
+ enabled: bool,
998
+ ) -> StaticPolicy:
999
+ """Toggle a static policy's enabled status.
1000
+
1001
+ Args:
1002
+ policy_id: Policy ID
1003
+ enabled: Whether the policy should be enabled
1004
+
1005
+ Returns:
1006
+ The updated policy
1007
+ """
1008
+ if self._config.debug:
1009
+ self._logger.debug("Toggling static policy", policy_id=policy_id, enabled=enabled)
1010
+
1011
+ response = await self._request(
1012
+ "PATCH",
1013
+ f"/api/v1/static-policies/{policy_id}",
1014
+ json_data={"enabled": enabled},
1015
+ )
1016
+ return StaticPolicy.model_validate(response)
1017
+
1018
+ async def get_effective_static_policies(
1019
+ self,
1020
+ options: EffectivePoliciesOptions | None = None,
1021
+ ) -> list[StaticPolicy]:
1022
+ """Get effective static policies with tier inheritance applied.
1023
+
1024
+ Args:
1025
+ options: Filtering options
1026
+
1027
+ Returns:
1028
+ List of effective policies
1029
+ """
1030
+ query_params: list[str] = []
1031
+ if options:
1032
+ if options.category:
1033
+ query_params.append(f"category={options.category.value}")
1034
+ if options.include_disabled:
1035
+ query_params.append("include_disabled=true")
1036
+ if options.include_overridden:
1037
+ query_params.append("include_overridden=true")
1038
+
1039
+ path = "/api/v1/static-policies/effective"
1040
+ if query_params:
1041
+ path = f"{path}?{'&'.join(query_params)}"
1042
+
1043
+ if self._config.debug:
1044
+ self._logger.debug("Getting effective static policies", path=path)
1045
+
1046
+ response = await self._request("GET", path)
1047
+ # Backend returns { static: [], dynamic: [], ... }, extract the static array
1048
+ policies = response.get("static", []) if isinstance(response, dict) else response
1049
+ return [StaticPolicy.model_validate(p) for p in policies]
1050
+
1051
+ async def test_pattern(
1052
+ self,
1053
+ pattern: str,
1054
+ test_inputs: list[str],
1055
+ ) -> TestPatternResult:
1056
+ """Test a regex pattern against sample inputs.
1057
+
1058
+ Args:
1059
+ pattern: Regex pattern to test
1060
+ test_inputs: Array of strings to test against
1061
+
1062
+ Returns:
1063
+ Test results showing matches
1064
+
1065
+ Example:
1066
+ >>> result = await client.test_pattern(
1067
+ ... r"\\b\\d{3}-\\d{2}-\\d{4}\\b",
1068
+ ... ["SSN: 123-45-6789", "No SSN here"]
1069
+ ... )
1070
+ """
1071
+ if self._config.debug:
1072
+ self._logger.debug(
1073
+ "Testing pattern",
1074
+ pattern=pattern,
1075
+ input_count=len(test_inputs),
1076
+ )
1077
+
1078
+ response = await self._request(
1079
+ "POST",
1080
+ "/api/v1/static-policies/test",
1081
+ json_data={"pattern": pattern, "inputs": test_inputs},
1082
+ )
1083
+ return TestPatternResult.model_validate(response)
1084
+
1085
+ async def get_static_policy_versions(
1086
+ self,
1087
+ policy_id: str,
1088
+ ) -> list[PolicyVersion]:
1089
+ """Get version history for a static policy.
1090
+
1091
+ Args:
1092
+ policy_id: Policy ID
1093
+
1094
+ Returns:
1095
+ Array of version history entries
1096
+ """
1097
+ if self._config.debug:
1098
+ self._logger.debug("Getting static policy versions", policy_id=policy_id)
1099
+
1100
+ response = await self._request(
1101
+ "GET",
1102
+ f"/api/v1/static-policies/{policy_id}/versions",
1103
+ )
1104
+ return [PolicyVersion.model_validate(v) for v in response]
1105
+
1106
+ # =========================================================================
1107
+ # Policy Override Methods (Enterprise)
1108
+ # =========================================================================
1109
+
1110
+ async def create_policy_override(
1111
+ self,
1112
+ policy_id: str,
1113
+ request: CreatePolicyOverrideRequest,
1114
+ ) -> PolicyOverride:
1115
+ """Create an override for a static policy.
1116
+
1117
+ Args:
1118
+ policy_id: ID of the policy to override
1119
+ request: Override configuration
1120
+
1121
+ Returns:
1122
+ The created override
1123
+
1124
+ Example:
1125
+ >>> override = await client.create_policy_override(
1126
+ ... "pol_123",
1127
+ ... CreatePolicyOverrideRequest(
1128
+ ... action=OverrideAction.WARN,
1129
+ ... reason="Temporarily relaxing for migration"
1130
+ ... )
1131
+ ... )
1132
+ """
1133
+ if self._config.debug:
1134
+ self._logger.debug(
1135
+ "Creating policy override",
1136
+ policy_id=policy_id,
1137
+ action=request.action.value,
1138
+ )
1139
+
1140
+ response = await self._request(
1141
+ "POST",
1142
+ f"/api/v1/static-policies/{policy_id}/override",
1143
+ json_data=request.model_dump(exclude_none=True, by_alias=True),
1144
+ )
1145
+ return PolicyOverride.model_validate(response)
1146
+
1147
+ async def delete_policy_override(self, policy_id: str) -> None:
1148
+ """Delete an override for a static policy.
1149
+
1150
+ Args:
1151
+ policy_id: ID of the policy whose override to delete
1152
+ """
1153
+ if self._config.debug:
1154
+ self._logger.debug("Deleting policy override", policy_id=policy_id)
1155
+
1156
+ await self._request("DELETE", f"/api/v1/static-policies/{policy_id}/override")
1157
+
1158
+ # =========================================================================
1159
+ # Dynamic Policy Methods
1160
+ # =========================================================================
1161
+
1162
+ async def list_dynamic_policies(
1163
+ self,
1164
+ options: ListDynamicPoliciesOptions | None = None,
1165
+ ) -> list[DynamicPolicy]:
1166
+ """List all dynamic policies with optional filtering.
1167
+
1168
+ Args:
1169
+ options: Filtering and pagination options
1170
+
1171
+ Returns:
1172
+ List of dynamic policies
1173
+ """
1174
+ params: list[str] = []
1175
+ if options:
1176
+ if options.category:
1177
+ params.append(f"category={options.category.value}")
1178
+ if options.tier:
1179
+ params.append(f"tier={options.tier.value}")
1180
+ if options.enabled is not None:
1181
+ params.append(f"enabled={str(options.enabled).lower()}")
1182
+ if options.limit:
1183
+ params.append(f"limit={options.limit}")
1184
+ if options.offset:
1185
+ params.append(f"offset={options.offset}")
1186
+ if options.sort_by:
1187
+ params.append(f"sort_by={options.sort_by}")
1188
+ if options.sort_order:
1189
+ params.append(f"sort_order={options.sort_order}")
1190
+ if options.search:
1191
+ params.append(f"search={options.search}")
1192
+
1193
+ path = "/api/v1/policies"
1194
+ if params:
1195
+ path = f"{path}?{'&'.join(params)}"
1196
+
1197
+ if self._config.debug:
1198
+ self._logger.debug("Listing dynamic policies", path=path)
1199
+
1200
+ response = await self._request("GET", path)
1201
+ return [DynamicPolicy.model_validate(p) for p in response]
1202
+
1203
+ async def get_dynamic_policy(self, policy_id: str) -> DynamicPolicy:
1204
+ """Get a specific dynamic policy by ID.
1205
+
1206
+ Args:
1207
+ policy_id: Policy ID
1208
+
1209
+ Returns:
1210
+ The dynamic policy
1211
+ """
1212
+ if self._config.debug:
1213
+ self._logger.debug("Getting dynamic policy", policy_id=policy_id)
1214
+
1215
+ response = await self._request("GET", f"/api/v1/policies/{policy_id}")
1216
+ return DynamicPolicy.model_validate(response)
1217
+
1218
+ async def create_dynamic_policy(
1219
+ self,
1220
+ request: CreateDynamicPolicyRequest,
1221
+ ) -> DynamicPolicy:
1222
+ """Create a new dynamic policy.
1223
+
1224
+ Args:
1225
+ request: Policy creation request
1226
+
1227
+ Returns:
1228
+ The created policy
1229
+ """
1230
+ if self._config.debug:
1231
+ self._logger.debug("Creating dynamic policy", name=request.name)
1232
+
1233
+ response = await self._request(
1234
+ "POST",
1235
+ "/api/v1/policies",
1236
+ json_data=request.model_dump(exclude_none=True, by_alias=True),
1237
+ )
1238
+ return DynamicPolicy.model_validate(response)
1239
+
1240
+ async def update_dynamic_policy(
1241
+ self,
1242
+ policy_id: str,
1243
+ request: UpdateDynamicPolicyRequest,
1244
+ ) -> DynamicPolicy:
1245
+ """Update an existing dynamic policy.
1246
+
1247
+ Args:
1248
+ policy_id: Policy ID
1249
+ request: Fields to update
1250
+
1251
+ Returns:
1252
+ The updated policy
1253
+ """
1254
+ if self._config.debug:
1255
+ self._logger.debug("Updating dynamic policy", policy_id=policy_id)
1256
+
1257
+ response = await self._request(
1258
+ "PUT",
1259
+ f"/api/v1/policies/{policy_id}",
1260
+ json_data=request.model_dump(exclude_none=True, by_alias=True),
1261
+ )
1262
+ return DynamicPolicy.model_validate(response)
1263
+
1264
+ async def delete_dynamic_policy(self, policy_id: str) -> None:
1265
+ """Delete a dynamic policy.
1266
+
1267
+ Args:
1268
+ policy_id: Policy ID
1269
+ """
1270
+ if self._config.debug:
1271
+ self._logger.debug("Deleting dynamic policy", policy_id=policy_id)
1272
+
1273
+ await self._request("DELETE", f"/api/v1/policies/{policy_id}")
1274
+
1275
+ async def toggle_dynamic_policy(
1276
+ self,
1277
+ policy_id: str,
1278
+ enabled: bool,
1279
+ ) -> DynamicPolicy:
1280
+ """Toggle a dynamic policy's enabled status.
1281
+
1282
+ Args:
1283
+ policy_id: Policy ID
1284
+ enabled: Whether the policy should be enabled
1285
+
1286
+ Returns:
1287
+ The updated policy
1288
+ """
1289
+ if self._config.debug:
1290
+ self._logger.debug("Toggling dynamic policy", policy_id=policy_id, enabled=enabled)
1291
+
1292
+ response = await self._request(
1293
+ "PATCH",
1294
+ f"/api/v1/policies/{policy_id}",
1295
+ json_data={"enabled": enabled},
1296
+ )
1297
+ return DynamicPolicy.model_validate(response)
1298
+
1299
+ async def get_effective_dynamic_policies(
1300
+ self,
1301
+ options: EffectivePoliciesOptions | None = None,
1302
+ ) -> list[DynamicPolicy]:
1303
+ """Get effective dynamic policies with tier inheritance applied.
1304
+
1305
+ Args:
1306
+ options: Filtering options
1307
+
1308
+ Returns:
1309
+ List of effective dynamic policies
1310
+ """
1311
+ query_params: list[str] = []
1312
+ if options:
1313
+ if options.category:
1314
+ query_params.append(f"category={options.category.value}")
1315
+ if options.include_disabled:
1316
+ query_params.append("include_disabled=true")
1317
+
1318
+ path = "/api/v1/policies/effective"
1319
+ if query_params:
1320
+ path = f"{path}?{'&'.join(query_params)}"
1321
+
1322
+ if self._config.debug:
1323
+ self._logger.debug("Getting effective dynamic policies", path=path)
1324
+
1325
+ response = await self._request("GET", path)
1326
+ return [DynamicPolicy.model_validate(p) for p in response]
1327
+
1328
+
1329
+ class SyncAxonFlow:
1330
+ """Synchronous wrapper for AxonFlow client.
1331
+
1332
+ Wraps all async methods for synchronous usage.
1333
+ """
1334
+
1335
+ __slots__ = ("_async_client", "_loop")
1336
+
1337
+ def __init__(self, async_client: AxonFlow) -> None:
1338
+ self._async_client = async_client
1339
+ self._loop: asyncio.AbstractEventLoop | None = None
1340
+
1341
+ def _get_loop(self) -> asyncio.AbstractEventLoop:
1342
+ """Get or create event loop."""
1343
+ if self._loop is None or self._loop.is_closed():
1344
+ try:
1345
+ self._loop = asyncio.get_event_loop()
1346
+ except RuntimeError:
1347
+ self._loop = asyncio.new_event_loop()
1348
+ asyncio.set_event_loop(self._loop)
1349
+ return self._loop
1350
+
1351
+ def __enter__(self) -> SyncAxonFlow:
1352
+ return self
1353
+
1354
+ def __exit__(
1355
+ self,
1356
+ exc_type: type[BaseException] | None,
1357
+ exc_val: BaseException | None,
1358
+ exc_tb: TracebackType | None,
1359
+ ) -> None:
1360
+ self.close()
1361
+
1362
+ def close(self) -> None:
1363
+ """Close the client."""
1364
+ self._get_loop().run_until_complete(self._async_client.close())
1365
+
1366
+ @property
1367
+ def config(self) -> AxonFlowConfig:
1368
+ """Get client configuration."""
1369
+ return self._async_client.config
1370
+
1371
+ def health_check(self) -> bool:
1372
+ """Check if AxonFlow Agent is healthy."""
1373
+ return self._get_loop().run_until_complete(self._async_client.health_check())
1374
+
1375
+ def execute_query(
1376
+ self,
1377
+ user_token: str,
1378
+ query: str,
1379
+ request_type: str,
1380
+ context: dict[str, Any] | None = None,
1381
+ ) -> ClientResponse:
1382
+ """Execute a query through AxonFlow."""
1383
+ return self._get_loop().run_until_complete(
1384
+ self._async_client.execute_query(user_token, query, request_type, context)
1385
+ )
1386
+
1387
+ def list_connectors(self) -> list[ConnectorMetadata]:
1388
+ """List all available MCP connectors."""
1389
+ return self._get_loop().run_until_complete(self._async_client.list_connectors())
1390
+
1391
+ def install_connector(self, request: ConnectorInstallRequest) -> None:
1392
+ """Install an MCP connector."""
1393
+ return self._get_loop().run_until_complete(self._async_client.install_connector(request))
1394
+
1395
+ def query_connector(
1396
+ self,
1397
+ user_token: str,
1398
+ connector_name: str,
1399
+ operation: str,
1400
+ params: dict[str, Any] | None = None,
1401
+ ) -> ConnectorResponse:
1402
+ """Query an MCP connector directly."""
1403
+ return self._get_loop().run_until_complete(
1404
+ self._async_client.query_connector(user_token, connector_name, operation, params)
1405
+ )
1406
+
1407
+ def generate_plan(
1408
+ self,
1409
+ query: str,
1410
+ domain: str | None = None,
1411
+ user_token: str | None = None,
1412
+ ) -> PlanResponse:
1413
+ """Generate a multi-agent execution plan."""
1414
+ return self._get_loop().run_until_complete(
1415
+ self._async_client.generate_plan(query, domain, user_token)
1416
+ )
1417
+
1418
+ def execute_plan(
1419
+ self,
1420
+ plan_id: str,
1421
+ user_token: str | None = None,
1422
+ ) -> PlanExecutionResponse:
1423
+ """Execute a previously generated plan."""
1424
+ return self._get_loop().run_until_complete(
1425
+ self._async_client.execute_plan(plan_id, user_token)
1426
+ )
1427
+
1428
+ def get_plan_status(self, plan_id: str) -> PlanExecutionResponse:
1429
+ """Get status of a running or completed plan."""
1430
+ return self._get_loop().run_until_complete(self._async_client.get_plan_status(plan_id))
1431
+
1432
+ # Gateway Mode sync wrappers
1433
+
1434
+ def get_policy_approved_context(
1435
+ self,
1436
+ user_token: str,
1437
+ query: str,
1438
+ data_sources: list[str] | None = None,
1439
+ context: dict[str, Any] | None = None,
1440
+ ) -> PolicyApprovalResult:
1441
+ """Perform policy pre-check before making LLM call."""
1442
+ return self._get_loop().run_until_complete(
1443
+ self._async_client.get_policy_approved_context(user_token, query, data_sources, context)
1444
+ )
1445
+
1446
+ def audit_llm_call(
1447
+ self,
1448
+ context_id: str,
1449
+ response_summary: str,
1450
+ provider: str,
1451
+ model: str,
1452
+ token_usage: TokenUsage,
1453
+ latency_ms: int,
1454
+ metadata: dict[str, Any] | None = None,
1455
+ ) -> AuditResult:
1456
+ """Report LLM call details for audit logging."""
1457
+ return self._get_loop().run_until_complete(
1458
+ self._async_client.audit_llm_call(
1459
+ context_id, response_summary, provider, model, token_usage, latency_ms, metadata
1460
+ )
1461
+ )
1462
+
1463
+ # Policy CRUD sync wrappers
1464
+
1465
+ def list_static_policies(
1466
+ self,
1467
+ options: ListStaticPoliciesOptions | None = None,
1468
+ ) -> list[StaticPolicy]:
1469
+ """List all static policies with optional filtering."""
1470
+ return self._get_loop().run_until_complete(self._async_client.list_static_policies(options))
1471
+
1472
+ def get_static_policy(self, policy_id: str) -> StaticPolicy:
1473
+ """Get a specific static policy by ID."""
1474
+ return self._get_loop().run_until_complete(self._async_client.get_static_policy(policy_id))
1475
+
1476
+ def create_static_policy(
1477
+ self,
1478
+ request: CreateStaticPolicyRequest,
1479
+ ) -> StaticPolicy:
1480
+ """Create a new static policy."""
1481
+ return self._get_loop().run_until_complete(self._async_client.create_static_policy(request))
1482
+
1483
+ def update_static_policy(
1484
+ self,
1485
+ policy_id: str,
1486
+ request: UpdateStaticPolicyRequest,
1487
+ ) -> StaticPolicy:
1488
+ """Update an existing static policy."""
1489
+ return self._get_loop().run_until_complete(
1490
+ self._async_client.update_static_policy(policy_id, request)
1491
+ )
1492
+
1493
+ def delete_static_policy(self, policy_id: str) -> None:
1494
+ """Delete a static policy."""
1495
+ return self._get_loop().run_until_complete(
1496
+ self._async_client.delete_static_policy(policy_id)
1497
+ )
1498
+
1499
+ def toggle_static_policy(
1500
+ self,
1501
+ policy_id: str,
1502
+ enabled: bool,
1503
+ ) -> StaticPolicy:
1504
+ """Toggle a static policy's enabled status."""
1505
+ return self._get_loop().run_until_complete(
1506
+ self._async_client.toggle_static_policy(policy_id, enabled)
1507
+ )
1508
+
1509
+ def get_effective_static_policies(
1510
+ self,
1511
+ options: EffectivePoliciesOptions | None = None,
1512
+ ) -> list[StaticPolicy]:
1513
+ """Get effective static policies with tier inheritance applied."""
1514
+ return self._get_loop().run_until_complete(
1515
+ self._async_client.get_effective_static_policies(options)
1516
+ )
1517
+
1518
+ def test_pattern(
1519
+ self,
1520
+ pattern: str,
1521
+ test_inputs: list[str],
1522
+ ) -> TestPatternResult:
1523
+ """Test a regex pattern against sample inputs."""
1524
+ return self._get_loop().run_until_complete(
1525
+ self._async_client.test_pattern(pattern, test_inputs)
1526
+ )
1527
+
1528
+ def get_static_policy_versions(
1529
+ self,
1530
+ policy_id: str,
1531
+ ) -> list[PolicyVersion]:
1532
+ """Get version history for a static policy."""
1533
+ return self._get_loop().run_until_complete(
1534
+ self._async_client.get_static_policy_versions(policy_id)
1535
+ )
1536
+
1537
+ # Policy override sync wrappers
1538
+
1539
+ def create_policy_override(
1540
+ self,
1541
+ policy_id: str,
1542
+ request: CreatePolicyOverrideRequest,
1543
+ ) -> PolicyOverride:
1544
+ """Create an override for a static policy."""
1545
+ return self._get_loop().run_until_complete(
1546
+ self._async_client.create_policy_override(policy_id, request)
1547
+ )
1548
+
1549
+ def delete_policy_override(self, policy_id: str) -> None:
1550
+ """Delete an override for a static policy."""
1551
+ return self._get_loop().run_until_complete(
1552
+ self._async_client.delete_policy_override(policy_id)
1553
+ )
1554
+
1555
+ # Dynamic policy sync wrappers
1556
+
1557
+ def list_dynamic_policies(
1558
+ self,
1559
+ options: ListDynamicPoliciesOptions | None = None,
1560
+ ) -> list[DynamicPolicy]:
1561
+ """List all dynamic policies with optional filtering."""
1562
+ return self._get_loop().run_until_complete(
1563
+ self._async_client.list_dynamic_policies(options)
1564
+ )
1565
+
1566
+ def get_dynamic_policy(self, policy_id: str) -> DynamicPolicy:
1567
+ """Get a specific dynamic policy by ID."""
1568
+ return self._get_loop().run_until_complete(self._async_client.get_dynamic_policy(policy_id))
1569
+
1570
+ def create_dynamic_policy(
1571
+ self,
1572
+ request: CreateDynamicPolicyRequest,
1573
+ ) -> DynamicPolicy:
1574
+ """Create a new dynamic policy."""
1575
+ return self._get_loop().run_until_complete(
1576
+ self._async_client.create_dynamic_policy(request)
1577
+ )
1578
+
1579
+ def update_dynamic_policy(
1580
+ self,
1581
+ policy_id: str,
1582
+ request: UpdateDynamicPolicyRequest,
1583
+ ) -> DynamicPolicy:
1584
+ """Update an existing dynamic policy."""
1585
+ return self._get_loop().run_until_complete(
1586
+ self._async_client.update_dynamic_policy(policy_id, request)
1587
+ )
1588
+
1589
+ def delete_dynamic_policy(self, policy_id: str) -> None:
1590
+ """Delete a dynamic policy."""
1591
+ return self._get_loop().run_until_complete(
1592
+ self._async_client.delete_dynamic_policy(policy_id)
1593
+ )
1594
+
1595
+ def toggle_dynamic_policy(
1596
+ self,
1597
+ policy_id: str,
1598
+ enabled: bool,
1599
+ ) -> DynamicPolicy:
1600
+ """Toggle a dynamic policy's enabled status."""
1601
+ return self._get_loop().run_until_complete(
1602
+ self._async_client.toggle_dynamic_policy(policy_id, enabled)
1603
+ )
1604
+
1605
+ def get_effective_dynamic_policies(
1606
+ self,
1607
+ options: EffectivePoliciesOptions | None = None,
1608
+ ) -> list[DynamicPolicy]:
1609
+ """Get effective dynamic policies with tier inheritance applied."""
1610
+ return self._get_loop().run_until_complete(
1611
+ self._async_client.get_effective_dynamic_policies(options)
1612
+ )