lexsi-sdk 0.1.16__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. lexsi_sdk/__init__.py +5 -0
  2. lexsi_sdk/client/__init__.py +0 -0
  3. lexsi_sdk/client/client.py +176 -0
  4. lexsi_sdk/common/__init__.py +0 -0
  5. lexsi_sdk/common/config/.env.prod +3 -0
  6. lexsi_sdk/common/constants.py +143 -0
  7. lexsi_sdk/common/enums.py +8 -0
  8. lexsi_sdk/common/environment.py +49 -0
  9. lexsi_sdk/common/monitoring.py +81 -0
  10. lexsi_sdk/common/trigger.py +75 -0
  11. lexsi_sdk/common/types.py +122 -0
  12. lexsi_sdk/common/utils.py +93 -0
  13. lexsi_sdk/common/validation.py +110 -0
  14. lexsi_sdk/common/xai_uris.py +197 -0
  15. lexsi_sdk/core/__init__.py +0 -0
  16. lexsi_sdk/core/agent.py +62 -0
  17. lexsi_sdk/core/alert.py +56 -0
  18. lexsi_sdk/core/case.py +618 -0
  19. lexsi_sdk/core/dashboard.py +131 -0
  20. lexsi_sdk/core/guardrails/__init__.py +0 -0
  21. lexsi_sdk/core/guardrails/guard_template.py +299 -0
  22. lexsi_sdk/core/guardrails/guardrail_autogen.py +554 -0
  23. lexsi_sdk/core/guardrails/guardrails_langgraph.py +525 -0
  24. lexsi_sdk/core/guardrails/guardrails_openai.py +541 -0
  25. lexsi_sdk/core/guardrails/openai_runner.py +1328 -0
  26. lexsi_sdk/core/model_summary.py +110 -0
  27. lexsi_sdk/core/organization.py +549 -0
  28. lexsi_sdk/core/project.py +5131 -0
  29. lexsi_sdk/core/synthetic.py +387 -0
  30. lexsi_sdk/core/text.py +595 -0
  31. lexsi_sdk/core/tracer.py +208 -0
  32. lexsi_sdk/core/utils.py +36 -0
  33. lexsi_sdk/core/workspace.py +325 -0
  34. lexsi_sdk/core/wrapper.py +766 -0
  35. lexsi_sdk/core/xai.py +306 -0
  36. lexsi_sdk/version.py +34 -0
  37. lexsi_sdk-0.1.16.dist-info/METADATA +100 -0
  38. lexsi_sdk-0.1.16.dist-info/RECORD +40 -0
  39. lexsi_sdk-0.1.16.dist-info/WHEEL +5 -0
  40. lexsi_sdk-0.1.16.dist-info/top_level.txt +1 -0
@@ -0,0 +1,525 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from datetime import datetime
5
+ from typing import Any, Callable, Dict, List, Optional, Tuple, TypedDict, Union
6
+ from lexsi_sdk.common.xai_uris import (
7
+ RUN_GUARDRAILS_URI,
8
+ RUN_GUARDRAILS_PARALLEL_URI
9
+ )
10
+ from lexsi_sdk.core.project import Project
11
+ from openinference.instrumentation.langchain import get_current_span
12
+ from opentelemetry import trace
13
+ import time
14
+ from .guard_template import Guard
15
+
16
+
17
+ class GuardrailRunResult(TypedDict, total=False):
18
+ """Structured response returned from guardrail API execution."""
19
+ details: Dict[str, Any]
20
+ validated_output: Any
21
+ validation_passed: bool
22
+ sanitized_output: Any
23
+ duration: float
24
+ latency: str
25
+ on_fail_action: str
26
+ retry_count: int
27
+ max_retries: int
28
+ start_time: str
29
+ end_time: str
30
+
31
+
32
+ class LangGraphGuardrail:
33
+ """
34
+ Decorator utility for applying Guardrails checks to LangGraph node inputs and outputs
35
+ by calling the Guardrails HTTP APIs.
36
+
37
+ Supports two modes:
38
+ - "adhoc": calls /guardrails/run_guardrail per guard passed in the decorator
39
+ - "configured": calls /guardrails/run to use project/model configured guardrails
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ project: Optional[Project],
45
+ default_apply_on: str = "input",
46
+ llm: Optional[Any] = None,
47
+ ) -> None:
48
+ """Initialize guardrail helper with project context and defaults."""
49
+ if project is not None:
50
+ self.client = project.api_client
51
+ self.project_name = project.project_name
52
+
53
+ self.default_apply_on = default_apply_on
54
+ self.logs: List[Dict[str, Any]] = []
55
+ self.max_retries = 1
56
+ self.retry_delay = 1.0
57
+ self.tracer = trace.get_tracer(__name__)
58
+ self.llm = llm
59
+
60
+ def guardrail(
61
+ self,
62
+ guards: Union[List[str], List[Dict[str, Any]], str, Dict[str, Any], None] = None,
63
+ action: str = "block",
64
+ apply_to: str = "both",
65
+ input_key: Optional[str] = None,
66
+ output_key: Optional[str] = None,
67
+ # process_entire_state: bool = False,
68
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
69
+ """
70
+ Decorator factory.
71
+
72
+ - action: 'block' | 'retry' | 'warn'. If validation fails:
73
+ - block: raise ValueError
74
+ - retry: replace with sanitized output when available
75
+ - warn: keep content, log only
76
+ - apply_to: 'input' | 'output' | 'both'
77
+ - input_key: Optional key in state to apply guardrail to for input (defaults to checking 'messages' or 'input')
78
+ - output_key: Optional key in result to apply guardrail to for output (defaults to checking 'messages' or treating as str)
79
+ - process_entire_state: If True, applies guardrails to all strings in the state/result recursively (overrides input_key/output_key)
80
+ """
81
+
82
+ if isinstance(guards, (str, dict)):
83
+ guards = [guards] # type: ignore[assignment]
84
+
85
+ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
86
+ """Decorator that wraps a LangGraph node with guardrail checks."""
87
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
88
+ """Execute guardrails around the wrapped node call."""
89
+ # In LangGraph, the first argument is the state dict
90
+ state = args[0] if args else kwargs.get("state")
91
+ node_name = getattr(func, "__name__", "unknown_node")
92
+
93
+ # Pre-process input
94
+ if state and apply_to in ("input", "both"):
95
+ input_content = self._get_content(state, input_key, is_input=True)
96
+ if input_content is not None:
97
+ processed = self._process_content(
98
+ content=input_content,
99
+ node_name=node_name,
100
+ content_type="input",
101
+ action=action,
102
+ guards=guards,
103
+ )
104
+ self._set_content(state, processed, input_key, is_input=True)
105
+
106
+ result = func(*args, **kwargs)
107
+
108
+ # Post-process output
109
+ if apply_to in ("output", "both"):
110
+
111
+ output_content = self._get_content(result, output_key, is_input=False)
112
+ if output_content is not None:
113
+ processed = self._process_content(
114
+ content=output_content,
115
+ node_name=node_name,
116
+ content_type="output",
117
+ action=action,
118
+ guards=guards,
119
+
120
+ )
121
+ if output_key is not None:
122
+ if isinstance(result, dict):
123
+ result[output_key] = processed
124
+ else:
125
+ if isinstance(result, dict):
126
+ if "messages" in result:
127
+ result["messages"] = processed
128
+ elif isinstance(result, str):
129
+ result = processed
130
+
131
+ return result
132
+
133
+ return wrapper
134
+
135
+ return decorator
136
+
137
+ def _get_content(self, data: Any, key: Optional[str], is_input: bool) -> Any:
138
+ """Extract guardrail-relevant content from the input or output structure."""
139
+ if key is not None:
140
+ if isinstance(data, dict) and key in data:
141
+ return data[key]
142
+ return None
143
+ else:
144
+ # Default behavior
145
+ if isinstance(data, dict):
146
+ if "messages" in data:
147
+ return data["messages"]
148
+ elif is_input and "input" in data:
149
+ return data["input"]
150
+ if not is_input and isinstance(data, str):
151
+ return data
152
+ return None # No content to process
153
+
154
+ def _set_content(self, data: Any, processed: Any, key: Optional[str], is_input: bool) -> None:
155
+ """Write processed content back into the state or result container."""
156
+ if key is not None:
157
+ if isinstance(data, dict):
158
+ data[key] = processed
159
+ else:
160
+ # Default set
161
+ if isinstance(data, dict):
162
+ if "messages" in data:
163
+ data["messages"] = processed
164
+ elif is_input and "input" in data:
165
+ data["input"] = processed
166
+
167
+ def _process_content(
168
+ self,
169
+ content: Any,
170
+ node_name: str,
171
+ content_type: str,
172
+ action: str,
173
+ guards: Optional[List[Union[str, Dict[str, Any]]]],
174
+ ) -> Any:
175
+ """Run guardrails over provided content and return sanitized result when needed."""
176
+ if not guards:
177
+ return content
178
+
179
+ is_list = isinstance(content, list)
180
+ if is_list and content:
181
+ content_to_process = content[-1].content
182
+ elif isinstance(content, str):
183
+ content_to_process = content
184
+ else:
185
+ return content
186
+
187
+ current_content = content_to_process
188
+
189
+ try:
190
+ parent_span = get_current_span()
191
+ if parent_span is not None:
192
+ ctx = trace.set_span_in_context(parent_span)
193
+ # Create parent span for input/output guardrails with node name for better tracing
194
+ with self.tracer.start_as_current_span(f"guardrails:{content_type}", context=ctx) as parent_gr_span:
195
+ # Set parent span attributes
196
+ parent_gr_span.set_attribute("node", str(node_name)) # Add node attribute
197
+ parent_gr_span.set_attribute("component", str(node_name))
198
+ parent_gr_span.set_attribute("content_type", str(content_type))
199
+
200
+ # Run all guardrails in parallel
201
+ parallel_result = self._apply_guardrail_parallel(current_content, guards)
202
+
203
+ if not parallel_result.get("success", False):
204
+ return current_content
205
+
206
+ # Set timing attributes only on parent span
207
+ parent_gr_span.set_attribute("start_time", str(parallel_result.get("start_time", "")))
208
+ parent_gr_span.set_attribute("end_time", str(parallel_result.get("end_time", "")))
209
+ parent_gr_span.set_attribute("duration", float(parallel_result.get("duration", 0.0)))
210
+
211
+ for guard_result in parallel_result.get("details", []):
212
+ guard_name = guard_result.get("name", "unknown")
213
+
214
+ run_result: GuardrailRunResult = {
215
+ "details": guard_result,
216
+ "validated_output": guard_result.get("validated_output"),
217
+ "validation_passed": guard_result.get("validation_passed", False),
218
+ "sanitized_output": guard_result.get("sanitized_output", current_content),
219
+ "duration": guard_result.get("duration", 0.0),
220
+ "latency": guard_result.get("latency", "0 ms"),
221
+ "start_time": parallel_result.get("start_time", ""),
222
+ "end_time": parallel_result.get("end_time", ""),
223
+ "retry_count": 0,
224
+ "max_retries": self.max_retries
225
+ }
226
+
227
+ run_result["response"] = guard_result
228
+ run_result["input"] = current_content
229
+
230
+ # Process each guard result with child spans
231
+ current_content = self._handle_action(
232
+ original=current_content,
233
+ run_result=run_result,
234
+ action=action,
235
+ node_name=node_name,
236
+ content_type=content_type,
237
+ guard_name=guard_name,
238
+ parent_span=parent_gr_span # Pass parent span
239
+ )
240
+
241
+ if is_list:
242
+ content[-1].content = current_content
243
+ return content
244
+ else:
245
+ return current_content
246
+
247
+ except Exception as e:
248
+ return content
249
+
250
+ def _apply_guardrail_parallel(
251
+ self,
252
+ content: Any,
253
+ guards: List[Union[str, Dict[str, Any]]],
254
+ ) -> Any:
255
+ """
256
+ Run multiple guardrails in parallel using batch endpoint
257
+
258
+ :param content: Content to validate against multiple guardrails.
259
+ :param guards: List of guardrails to run in parallel.
260
+ :return: Response dict containing success status and validation details.
261
+ """
262
+ try:
263
+ # Convert string guards to proper dict format with name and class
264
+ guard_specs = []
265
+ for guard in guards:
266
+ guard_specs.append(guard)
267
+
268
+ # Prepare payload according to RunGuardParallelPayload schema
269
+ payload = {
270
+ "input_data": content,
271
+ "guards": guard_specs # List of properly formatted guard specs
272
+ }
273
+
274
+ import requests
275
+ start_time = datetime.now()
276
+ response = self.client.post(
277
+ RUN_GUARDRAILS_PARALLEL_URI,
278
+ payload=payload,
279
+ )
280
+ end_time = datetime.now()
281
+ result = response
282
+ result.update({
283
+ "start_time": start_time.isoformat(),
284
+ "end_time": end_time.isoformat(),
285
+ "duration": (end_time - start_time).total_seconds()
286
+ })
287
+
288
+ return result
289
+
290
+ except Exception as e:
291
+ return {
292
+ "success": False,
293
+ "details": f"Failed to run guardrails in parallel: {str(e)}",
294
+ "start_time": datetime.now().isoformat(),
295
+ "end_time": datetime.now().isoformat()
296
+ }
297
+
298
+ def _apply_guardrail_with_retry(
299
+ self,
300
+ content: Any,
301
+ guard_spec: Dict[str, Any],
302
+ node_name: str,
303
+ content_type: str,
304
+ action: str,
305
+ ) -> Any:
306
+ """Apply a guardrail, optionally retrying with sanitized output."""
307
+ current_content = content
308
+ retry_count = 0
309
+
310
+ if action == "retry":
311
+ while retry_count <= self.max_retries:
312
+ run_result = self._call_run_guardrail(current_content, guard_spec, content_type)
313
+ validation_passed = bool(run_result.get("validation_passed", True))
314
+ detected_issue = not validation_passed or not run_result.get("success", True)
315
+
316
+ if detected_issue and self.llm is not None and retry_count < self.max_retries:
317
+ prompt = self._build_sanitize_prompt(guard_spec.get("name", "unknown"), current_content, content_type)
318
+ try:
319
+ sanitized = self.llm.invoke(prompt)
320
+ if hasattr(sanitized, "content"):
321
+ sanitized = sanitized.content
322
+ except Exception:
323
+ sanitized = current_content
324
+ retry_action = f"retry_{retry_count+1}"
325
+ self._handle_action(
326
+ original=current_content,
327
+ run_result=run_result,
328
+ action=retry_action,
329
+ node_name=node_name,
330
+ content_type=content_type,
331
+ guard_name=guard_spec.get("name") or guard_spec.get("class", "unknown"),
332
+ )
333
+ current_content = sanitized
334
+ retry_count += 1
335
+ time.sleep(self.retry_delay)
336
+ continue
337
+ else:
338
+ return self._handle_action(
339
+ original=current_content,
340
+ run_result=run_result,
341
+ action=f"retry_{retry_count}" if retry_count > 0 else action,
342
+ node_name=node_name,
343
+ content_type=content_type,
344
+ guard_name=guard_spec.get("name") or guard_spec.get("class", "unknown"),
345
+ )
346
+ return current_content
347
+ else:
348
+ # Only one check for non-retry actions
349
+ run_result = self._call_run_guardrail(current_content, guard_spec, content_type)
350
+ return self._handle_action(
351
+ original=current_content,
352
+ run_result=run_result,
353
+ action=action,
354
+ node_name=node_name,
355
+ content_type=content_type,
356
+ guard_name=guard_spec.get("name") or guard_spec.get("class", "unknown"),
357
+ )
358
+
359
+ # --------- HTTP calls ---------
360
+
361
+
362
+ def _call_run_guardrail(self, input_data: Any, guard: Dict[str, Any], content_type: Any) -> GuardrailRunResult:
363
+ """Invoke the guardrail service for a single guard."""
364
+ uri = RUN_GUARDRAILS_URI
365
+ input = input_data
366
+
367
+ start_time = datetime.now()
368
+ try:
369
+ body = {"input_data": input, "guard": guard}
370
+ data = self.client.post(uri, body)
371
+ end_time = datetime.now()
372
+
373
+ details = data.get("details", {}) if isinstance(data, dict) else {}
374
+ result: GuardrailRunResult = {
375
+ "success": bool(data.get("success", False)) if isinstance(data, dict) else False,
376
+ "details": details if isinstance(details, dict) else {},
377
+ "start_time": start_time.isoformat(),
378
+ "end_time": end_time.isoformat(),
379
+ }
380
+ if "duration" not in details:
381
+ result["duration"] = (end_time - start_time).total_seconds()
382
+ if isinstance(details, dict):
383
+ if "validated_output" in details:
384
+ result["validated_output"] = details["validated_output"]
385
+ if "validation_passed" in details:
386
+ result["validation_passed"] = details["validation_passed"]
387
+ if "sanitized_output" in details:
388
+ result["sanitized_output"] = details["sanitized_output"]
389
+ if "duration" in details:
390
+ result["duration"] = details["duration"]
391
+ if "latency" in details:
392
+ result["latency"] = details["latency"]
393
+
394
+ result["retry_count"] = 0
395
+ result["max_retries"] = self.max_retries
396
+
397
+ result["response"] = data
398
+ result["input"] = input
399
+
400
+ return result
401
+
402
+ except Exception as exc:
403
+ end_time = datetime.now() # Still capture end time on exception
404
+ raise exc
405
+
406
+ # --------- Action handling ---------
407
+ def _handle_action(
408
+ self,
409
+ original: Any,
410
+ run_result: GuardrailRunResult,
411
+ action: str,
412
+ node_name: str,
413
+ content_type: str,
414
+ guard_name: str,
415
+ parent_span: Optional[Any] = None,
416
+ ) -> Any:
417
+ """Handle guardrail results according to configured action."""
418
+ validation_passed = bool(run_result.get("validation_passed", True))
419
+ detected_issue = not validation_passed or not run_result.get("success", True)
420
+
421
+ def create_child_span(is_issue: bool):
422
+ """Record a child tracing span for a single guard result."""
423
+
424
+ with self.tracer.start_as_current_span(
425
+ f"guard: {guard_name}",
426
+ context=trace.set_span_in_context(parent_span)
427
+ ) as gr_span:
428
+ gr_span.set_attribute("component", str(node_name))
429
+ gr_span.set_attribute("guard", str(guard_name))
430
+ gr_span.set_attribute("content_type", str(content_type))
431
+ gr_span.set_attribute("detected", is_issue)
432
+ gr_span.set_attribute("action", action)
433
+
434
+ # Don't set timing attributes on child spans
435
+ gr_span.set_attribute("input.value", self._safe_str(run_result.get("input")))
436
+ gr_span.set_attribute("output.value", json.dumps(run_result.get("response")))
437
+
438
+
439
+ if detected_issue:
440
+ if parent_span is not None:
441
+ create_child_span(True)
442
+
443
+ if action == "block":
444
+ raise ValueError(f"Guardrail '{guard_name}' detected an issue in {content_type}. Operation blocked.")
445
+ elif "retry" in action:
446
+ return run_result.get("sanitized_output", original)
447
+ else: # warn
448
+ return original
449
+
450
+ # Handle successful validation
451
+ if parent_span is not None:
452
+ create_child_span(False)
453
+
454
+ return original
455
+
456
+ def _build_sanitize_prompt(self, guard_name: str, content: Any, content_type: str) -> str:
457
+ """Construct a prompt asking the LLM to sanitize problematic content."""
458
+ instructions = {
459
+ "Detect PII": "Sanitize the following text by removing or masking any personally identifiable information (PII). Do not change anything else.",
460
+ "NSFW Text": "Sanitize the following text by removing or masking any not safe for work (NSFW) content. Do not change anything else.",
461
+ "Ban List": "Sanitize the following text by removing or masking any banned words. Do not change anything else.",
462
+ "Bias Check": "Sanitize the following text by removing or masking any biased language. Do not change anything else.",
463
+ "Competitor Check": "Sanitize the following text by removing or masking any competitor names. Do not change anything else.",
464
+ "Correct Language": "Sanitize the following text by correcting the language to the expected language. Do not change anything else.",
465
+ "Gibberish Text": "Sanitize the following text by removing or correcting any gibberish. Do not change anything else.",
466
+ "Profanity Free": "Sanitize the following text by removing or masking any profanity. Do not change anything else.",
467
+ "Secrets Present": "Sanitize the following text by removing or masking any secrets. Do not change anything else.",
468
+ "Toxic Language": "Sanitize the following text by removing or masking any toxic language. Do not change anything else.",
469
+ "Contains String": "Sanitize the following text by removing or masking the specified substring. Do not change anything else.",
470
+ "Detect Jailbreak": "Sanitize the following text by removing or masking any jailbreak attempts. Do not change anything else.",
471
+ "Endpoint Is Reachable": "Sanitize the following text by ensuring any mentioned endpoints are reachable. Do not change anything else.",
472
+ "Ends With": "Sanitize the following text by ensuring it ends with the specified string. Do not change anything else.",
473
+ "Has Url": "Sanitize the following text by removing or masking any URLs. Do not change anything else.",
474
+ "Lower Case": "Sanitize the following text by converting it to lower case. Do not change anything else.",
475
+ "Mentions Drugs": "Sanitize the following text by removing or masking any mentions of drugs. Do not change anything else.",
476
+ "One Line": "Sanitize the following text by ensuring it is a single line. Do not change anything else.",
477
+ "Reading Time": "Sanitize the following text by ensuring its reading time matches the specified value. Do not change anything else.",
478
+ "Redundant Sentences": "Sanitize the following text by removing redundant sentences. Do not change anything else.",
479
+ "Regex Match": "Sanitize the following text by ensuring it matches the specified regex. Do not change anything else.",
480
+ "Sql Column Presence": "Sanitize the following text by ensuring specified SQL columns are present. Do not change anything else.",
481
+ "Two Words": "Sanitize the following text by ensuring it contains only two words. Do not change anything else.",
482
+ "Upper Case": "Sanitize the following text by converting it to upper case. Do not change anything else.",
483
+ "Valid Choices": "Sanitize the following text by ensuring it matches one of the valid choices. Do not change anything else.",
484
+ "Valid Json": "Sanitize the following text by ensuring it is valid JSON. Do not change anything else.",
485
+ "Valid Length": "Sanitize the following text by ensuring its length is valid. Do not change anything else.",
486
+ "Valid Range": "Sanitize the following text by ensuring its value is within the valid range. Do not change anything else.",
487
+ "Valid URL": "Sanitize the following text by ensuring it is a valid URL. Do not change anything else.",
488
+ "Web Sanitization": "Sanitize the following text by removing any unsafe web content. Do not change anything else.",
489
+ }
490
+ instruction = instructions.get(guard_name, "Sanitize the following text according to the guardrail requirements. Do not change anything else.")
491
+ prompt = f"{instruction}\n\nContent:\n{content}"
492
+ return prompt
493
+
494
+ @staticmethod
495
+ def _safe_str(value: Any) -> str:
496
+ """Safely stringify potentially complex values for logging."""
497
+ try:
498
+ if isinstance(value, (str, int, float, bool)) or value is None:
499
+ return str(value)
500
+ if hasattr(value, "content"):
501
+ return str(getattr(value, "content", ""))
502
+ if isinstance(value, (list, tuple)):
503
+ parts = []
504
+ for item in value:
505
+ parts.append(Guard._safe_str(item) if hasattr(Guard, "_safe_str") else str(item))
506
+ return ", ".join(parts)
507
+ if isinstance(value, dict):
508
+ safe_dict: Dict[str, Any] = {}
509
+ for k, v in value.items():
510
+ key = str(k)
511
+ if isinstance(v, (str, int, float, bool)) or v is None:
512
+ safe_dict[key] = v
513
+ elif hasattr(v, "content"):
514
+ safe_dict[key] = str(getattr(v, "content", ""))
515
+ else:
516
+ safe_dict[key] = str(v)
517
+ return json.dumps(safe_dict, ensure_ascii=False)
518
+ return str(value)
519
+ except Exception:
520
+ return "<unserializable>"
521
+
522
+ # Convenience function for quick guardrail setup
523
+ def create_guardrail(project: Project) -> LangGraphGuardrail:
524
+ """Quick factory function to create a guardrail instance with a project"""
525
+ return LangGraphGuardrail(project=project)