qaiss-sdk 0.1.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.
- qaiss/__init__.py +70 -0
- qaiss/asgi.py +117 -0
- qaiss/client.py +283 -0
- qaiss/context.py +80 -0
- qaiss/core.py +244 -0
- qaiss/decorator.py +221 -0
- qaiss/django_middleware.py +84 -0
- qaiss/instrumentation.py +1766 -0
- qaiss/middleware.py +13 -0
- qaiss/models.py +84 -0
- qaiss/redaction.py +152 -0
- qaiss/transport.py +143 -0
- qaiss/truncation.py +102 -0
- qaiss/wsgi.py +129 -0
- qaiss_sdk-0.1.0.dist-info/METADATA +148 -0
- qaiss_sdk-0.1.0.dist-info/RECORD +19 -0
- qaiss_sdk-0.1.0.dist-info/WHEEL +5 -0
- qaiss_sdk-0.1.0.dist-info/licenses/LICENSE +207 -0
- qaiss_sdk-0.1.0.dist-info/top_level.txt +1 -0
qaiss/__init__.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""
|
|
2
|
+
QAISS SDK: Production-grade Observability, Telemetry & Guardrails Client for AI Agents.
|
|
3
|
+
Collects and structures ground-truth telemetry for the QAISS Hallucination & Safety Evaluation Engine.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Optional, Any
|
|
7
|
+
from .client import QaissClient
|
|
8
|
+
from .context import set_context, get_current_security_context, clear_context, get_next_step_index
|
|
9
|
+
from .models import QaissEventModel, SecurityContext, LLMMetadata, EventType
|
|
10
|
+
from .redaction import scrub_payload, redact_string, is_valid_luhn
|
|
11
|
+
from .truncation import truncate_payload
|
|
12
|
+
from .core import QaissRequestEngine
|
|
13
|
+
from .asgi import QaissASGIMiddleware
|
|
14
|
+
from .wsgi import QaissWSGIMiddleware
|
|
15
|
+
from .django_middleware import QaissDjangoMiddleware
|
|
16
|
+
from .middleware import QaissMiddleware, QaissFastAPIMiddleware
|
|
17
|
+
from .instrumentation import wrap_tools, auto_instrument
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0"
|
|
20
|
+
_global_client: Optional[QaissClient] = None
|
|
21
|
+
|
|
22
|
+
def init(
|
|
23
|
+
api_key: Optional[str] = None,
|
|
24
|
+
backend_url: Optional[str] = None,
|
|
25
|
+
environment: Optional[str] = None,
|
|
26
|
+
debug: bool = False,
|
|
27
|
+
**kwargs: Any
|
|
28
|
+
) -> QaissClient:
|
|
29
|
+
"""
|
|
30
|
+
Sentry-style single-line automatic initialization.
|
|
31
|
+
Automatically patches all AI libraries (OpenAI, Anthropic, Requests, HTTPX)
|
|
32
|
+
and Agent Framework constructors (Agno, LangChain, CrewAI, LlamaIndex) process-wide.
|
|
33
|
+
"""
|
|
34
|
+
global _global_client
|
|
35
|
+
_global_client = QaissClient(
|
|
36
|
+
api_key=api_key,
|
|
37
|
+
backend_url=backend_url,
|
|
38
|
+
environment=environment,
|
|
39
|
+
debug=debug,
|
|
40
|
+
auto_track_libraries=True,
|
|
41
|
+
**kwargs
|
|
42
|
+
)
|
|
43
|
+
return _global_client
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"init",
|
|
47
|
+
"QaissClient",
|
|
48
|
+
"QaissRequestEngine",
|
|
49
|
+
"QaissASGIMiddleware",
|
|
50
|
+
"QaissWSGIMiddleware",
|
|
51
|
+
"QaissDjangoMiddleware",
|
|
52
|
+
"QaissMiddleware",
|
|
53
|
+
"QaissFastAPIMiddleware",
|
|
54
|
+
"wrap_tools",
|
|
55
|
+
"auto_instrument",
|
|
56
|
+
"set_context",
|
|
57
|
+
"get_current_security_context",
|
|
58
|
+
"clear_context",
|
|
59
|
+
"get_next_step_index",
|
|
60
|
+
"QaissEventModel",
|
|
61
|
+
"SecurityContext",
|
|
62
|
+
"LLMMetadata",
|
|
63
|
+
"EventType",
|
|
64
|
+
"scrub_payload",
|
|
65
|
+
"redact_string",
|
|
66
|
+
"is_valid_luhn",
|
|
67
|
+
"truncate_payload"
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
__version__ = "0.1.0"
|
qaiss/asgi.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""
|
|
2
|
+
QAISS Universal ASGI Middleware. Speaks raw ASGI 3.0 — no Starlette/FastAPI import.
|
|
3
|
+
Works with FastAPI, Starlette, Litestar, Quart, BlackSheep, Django (ASGI mode), and
|
|
4
|
+
any other ASGI-compliant framework, current or future, with zero new SDK code.
|
|
5
|
+
|
|
6
|
+
IMPORTANT: unlike Starlette's BaseHTTPMiddleware (which must fully buffer
|
|
7
|
+
response.body_iterator before anything reaches the client), this middleware forwards
|
|
8
|
+
every send() call to the real transport immediately. Streaming/SSE responses (e.g.
|
|
9
|
+
token-by-token chat output) are NOT delayed or buffered for the client — only our own
|
|
10
|
+
internal copy is buffered, capped by max_body_bytes.
|
|
11
|
+
"""
|
|
12
|
+
import logging
|
|
13
|
+
from typing import Any, List, Optional
|
|
14
|
+
|
|
15
|
+
from .core import QaissRequestEngine
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("qaiss.asgi")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class QaissASGIMiddleware:
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
app,
|
|
24
|
+
qaiss_client: Any,
|
|
25
|
+
include_paths: Optional[List[str]] = None,
|
|
26
|
+
max_body_bytes: int = 1_000_000,
|
|
27
|
+
**engine_kwargs,
|
|
28
|
+
):
|
|
29
|
+
self.app = app
|
|
30
|
+
self.engine = QaissRequestEngine(qaiss_client, **engine_kwargs)
|
|
31
|
+
self.include_paths = set(include_paths) if include_paths else None
|
|
32
|
+
self.max_body_bytes = max_body_bytes
|
|
33
|
+
|
|
34
|
+
def _should_track(self, path: str) -> bool:
|
|
35
|
+
if not self.include_paths:
|
|
36
|
+
return True
|
|
37
|
+
return any(path.startswith(p) for p in self.include_paths)
|
|
38
|
+
|
|
39
|
+
async def __call__(self, scope, receive, send):
|
|
40
|
+
# Non-HTTP scopes (websocket, lifespan) pass straight through untouched.
|
|
41
|
+
if scope["type"] != "http" or not self._should_track(scope.get("path", "")):
|
|
42
|
+
await self.app(scope, receive, send)
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
headers = {k.decode("latin-1"): v.decode("latin-1") for k, v in scope.get("headers", [])}
|
|
46
|
+
method = scope.get("method", "GET")
|
|
47
|
+
path = scope.get("path", "")
|
|
48
|
+
|
|
49
|
+
content_length_header = headers.get("content-length")
|
|
50
|
+
content_length = int(content_length_header) if content_length_header and content_length_header.isdigit() else None
|
|
51
|
+
|
|
52
|
+
# If request is explicitly larger than max_body_bytes, do not pre-read or buffer.
|
|
53
|
+
# Pass receive directly to downstream app so streaming uploads work with 0 memory overhead.
|
|
54
|
+
if content_length is not None and content_length > self.max_body_bytes:
|
|
55
|
+
state = self.engine.before_request(method, path, headers, None)
|
|
56
|
+
app_receive = receive
|
|
57
|
+
else:
|
|
58
|
+
buffered_messages = []
|
|
59
|
+
body_chunks = []
|
|
60
|
+
total_bytes = 0
|
|
61
|
+
more_body = True
|
|
62
|
+
capture_request_body = True
|
|
63
|
+
|
|
64
|
+
while more_body:
|
|
65
|
+
message = await receive()
|
|
66
|
+
buffered_messages.append(message)
|
|
67
|
+
chunk = message.get("body", b"")
|
|
68
|
+
total_bytes += len(chunk)
|
|
69
|
+
if total_bytes <= self.max_body_bytes and capture_request_body:
|
|
70
|
+
body_chunks.append(chunk)
|
|
71
|
+
else:
|
|
72
|
+
capture_request_body = False
|
|
73
|
+
more_body = message.get("more_body", False)
|
|
74
|
+
|
|
75
|
+
body_bytes = b"".join(body_chunks) if capture_request_body else None
|
|
76
|
+
state = self.engine.before_request(method, path, headers, body_bytes)
|
|
77
|
+
|
|
78
|
+
msg_idx = 0
|
|
79
|
+
|
|
80
|
+
async def replay_receive():
|
|
81
|
+
nonlocal msg_idx
|
|
82
|
+
if msg_idx < len(buffered_messages):
|
|
83
|
+
msg = buffered_messages[msg_idx]
|
|
84
|
+
msg_idx += 1
|
|
85
|
+
return msg
|
|
86
|
+
return await receive()
|
|
87
|
+
|
|
88
|
+
app_receive = replay_receive
|
|
89
|
+
|
|
90
|
+
response_status = {"code": 200}
|
|
91
|
+
response_headers_list = []
|
|
92
|
+
response_body_chunks = []
|
|
93
|
+
bytes_seen = {"total": 0}
|
|
94
|
+
capture_response = {"on": True}
|
|
95
|
+
|
|
96
|
+
async def send_wrapper(message):
|
|
97
|
+
if message["type"] == "http.response.start":
|
|
98
|
+
response_status["code"] = message["status"]
|
|
99
|
+
response_headers_list.extend(
|
|
100
|
+
(k.decode("latin-1"), v.decode("latin-1")) for k, v in message.get("headers", [])
|
|
101
|
+
)
|
|
102
|
+
elif message["type"] == "http.response.body":
|
|
103
|
+
chunk = message.get("body", b"")
|
|
104
|
+
bytes_seen["total"] += len(chunk)
|
|
105
|
+
if capture_response["on"]:
|
|
106
|
+
if bytes_seen["total"] > self.max_body_bytes:
|
|
107
|
+
capture_response["on"] = False # stop buffering our copy; client is unaffected
|
|
108
|
+
else:
|
|
109
|
+
response_body_chunks.append(chunk)
|
|
110
|
+
await send(message) # ALWAYS forward immediately — this preserves streaming
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
await self.app(scope, app_receive, send_wrapper)
|
|
114
|
+
finally:
|
|
115
|
+
resp_headers = dict(response_headers_list)
|
|
116
|
+
resp_body = b"".join(response_body_chunks) if capture_response["on"] else None
|
|
117
|
+
self.engine.after_request(state, response_status["code"], resp_headers, resp_body)
|
qaiss/client.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import random
|
|
3
|
+
import uuid
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Optional, List, Dict, Any, Set, Union
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
|
|
9
|
+
from opentelemetry import trace
|
|
10
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
11
|
+
from opentelemetry.trace import ProxyTracerProvider
|
|
12
|
+
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
|
|
13
|
+
|
|
14
|
+
from .transport import BackgroundTransport
|
|
15
|
+
from .decorator import create_track_tool_decorator, create_track_agent_decorator, format_utc_timestamp
|
|
16
|
+
from .models import QaissEventModel, SecurityContext, LLMMetadata, EventType
|
|
17
|
+
from .redaction import scrub_payload
|
|
18
|
+
from .truncation import truncate_payload
|
|
19
|
+
from .context import set_context, get_current_security_context, clear_context, get_next_step_index
|
|
20
|
+
from .instrumentation import wrap_tools, auto_instrument
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("qaiss.client")
|
|
23
|
+
_propagator = TraceContextTextMapPropagator()
|
|
24
|
+
|
|
25
|
+
class QaissClient:
|
|
26
|
+
"""
|
|
27
|
+
Production-grade Observability, Telemetry & Guardrails Client for AI Agents.
|
|
28
|
+
|
|
29
|
+
Dual-Purpose Instrumentation:
|
|
30
|
+
1. Global Process-Wide Auto-Tracking (No arguments):
|
|
31
|
+
Call `client.wrap_tools()` to automatically patch installed libraries (OpenAI, Anthropic, Requests, HTTPX).
|
|
32
|
+
All outgoing calls across your entire app are tracked automatically with zero further code.
|
|
33
|
+
|
|
34
|
+
2. Explicit Tool Wrapping (With arguments):
|
|
35
|
+
Call `client.wrap_tools(tools)` to instrument specific tool lists, LangChain/LlamaIndex/CrewAI/Agno tools,
|
|
36
|
+
or custom class instances.
|
|
37
|
+
|
|
38
|
+
3. Manual Decorators:
|
|
39
|
+
Use `@client.track_tool(...)` for proprietary DB or internal API functions.
|
|
40
|
+
"""
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
api_key: Optional[str] = None,
|
|
44
|
+
backend_url: Optional[str] = None,
|
|
45
|
+
environment: Optional[str] = None,
|
|
46
|
+
enabled: bool = True,
|
|
47
|
+
debug: bool = False,
|
|
48
|
+
sample_rate: float = 1.0,
|
|
49
|
+
tracked_users: Optional[List[str]] = None,
|
|
50
|
+
include_tools: Optional[List[str]] = None,
|
|
51
|
+
exclude_tools: Optional[List[str]] = None,
|
|
52
|
+
client_id: Optional[str] = None,
|
|
53
|
+
auto_track_libraries: bool = True,
|
|
54
|
+
track_db: bool = False
|
|
55
|
+
):
|
|
56
|
+
self.api_key = api_key or os.environ.get("QAISS_API_KEY", "")
|
|
57
|
+
self.backend_url = backend_url or os.environ.get("QAISS_BACKEND_URL", "https://api.qaiss.com/v1/events")
|
|
58
|
+
self.environment = environment or os.environ.get("QAISS_ENVIRONMENT", "production")
|
|
59
|
+
self.enabled = enabled
|
|
60
|
+
self.debug = debug
|
|
61
|
+
self.sample_rate = max(0.0, min(1.0, sample_rate))
|
|
62
|
+
self.client_id = client_id or f"client_{uuid.uuid4().hex[:8]}"
|
|
63
|
+
self.auto_track_libraries = auto_track_libraries
|
|
64
|
+
self.track_db = track_db
|
|
65
|
+
self._auto_tracked_libraries: Dict[str, bool] = {}
|
|
66
|
+
|
|
67
|
+
self.tracked_users: Optional[Set[str]] = {str(u) for u in tracked_users} if tracked_users else None
|
|
68
|
+
self.include_tools: Optional[Set[str]] = set(include_tools) if include_tools else None
|
|
69
|
+
self.exclude_tools: Optional[Set[str]] = set(exclude_tools) if exclude_tools else None
|
|
70
|
+
|
|
71
|
+
provider = trace.get_tracer_provider()
|
|
72
|
+
if isinstance(provider, ProxyTracerProvider) or provider is None:
|
|
73
|
+
self._tracer_provider = TracerProvider()
|
|
74
|
+
self._tracer = self._tracer_provider.get_tracer(f"qaiss-sdk-{self.client_id}")
|
|
75
|
+
else:
|
|
76
|
+
self._tracer = trace.get_tracer(f"qaiss-sdk-{self.client_id}")
|
|
77
|
+
|
|
78
|
+
self._transport = BackgroundTransport(backend_url=self.backend_url, api_key=self.api_key)
|
|
79
|
+
|
|
80
|
+
self.track_tool = create_track_tool_decorator(self)
|
|
81
|
+
self.track_agent = create_track_agent_decorator(self)
|
|
82
|
+
|
|
83
|
+
# Opt-in automatic process-wide library patching at construction time
|
|
84
|
+
if self.auto_track_libraries and self.enabled:
|
|
85
|
+
self._auto_tracked_libraries = self.wrap_tools()
|
|
86
|
+
|
|
87
|
+
def wrap_tools(
|
|
88
|
+
self,
|
|
89
|
+
tools: Any = None,
|
|
90
|
+
track_db: Optional[bool] = None
|
|
91
|
+
) -> Union[Dict[str, bool], List[Any], Dict[str, Any], Any]:
|
|
92
|
+
"""
|
|
93
|
+
Dual-Purpose Tool & Library Instrumentor.
|
|
94
|
+
|
|
95
|
+
Modes:
|
|
96
|
+
- `wrap_tools()` (no args): Auto-patches all installed libraries (OpenAI, Anthropic, Gemini, Requests, HTTPX, Agent frameworks)
|
|
97
|
+
- `wrap_tools(tools)`: Wraps functions, toolkits, LangChain/CrewAI tools, or custom class methods.
|
|
98
|
+
"""
|
|
99
|
+
use_track_db = self.track_db if track_db is None else track_db
|
|
100
|
+
return wrap_tools(tools=tools, client=self, track_db=use_track_db)
|
|
101
|
+
|
|
102
|
+
def auto_instrument(self, frameworks: Optional[List[str]] = None) -> Dict[str, bool]:
|
|
103
|
+
"""
|
|
104
|
+
[DEPRECATED] Use client.wrap_tools() with no arguments instead.
|
|
105
|
+
Auto-instruments installed AI and HTTP libraries process-wide.
|
|
106
|
+
"""
|
|
107
|
+
logger.debug("QAISS: auto_instrument() is deprecated and will be removed in a future release. Use wrap_tools() with no arguments instead.")
|
|
108
|
+
return self.wrap_tools()
|
|
109
|
+
|
|
110
|
+
def get_tracer(self):
|
|
111
|
+
return self._tracer
|
|
112
|
+
|
|
113
|
+
def set_context(
|
|
114
|
+
self,
|
|
115
|
+
authenticated_user_id: str,
|
|
116
|
+
resource_owner_id: Optional[str] = None,
|
|
117
|
+
tenant_id: Optional[str] = None,
|
|
118
|
+
scopes_provided: Optional[List[str]] = None
|
|
119
|
+
) -> None:
|
|
120
|
+
set_context(
|
|
121
|
+
authenticated_user_id=authenticated_user_id,
|
|
122
|
+
resource_owner_id=resource_owner_id,
|
|
123
|
+
tenant_id=tenant_id,
|
|
124
|
+
scopes_provided=scopes_provided,
|
|
125
|
+
client_id=self.client_id
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def get_context(self) -> Optional[Dict[str, Any]]:
|
|
129
|
+
return get_current_security_context(client_id=self.client_id)
|
|
130
|
+
|
|
131
|
+
def clear_context(self) -> None:
|
|
132
|
+
clear_context(client_id=self.client_id)
|
|
133
|
+
|
|
134
|
+
def get_next_step_index(self) -> int:
|
|
135
|
+
return get_next_step_index(client_id=self.client_id)
|
|
136
|
+
|
|
137
|
+
def get_active_traceparent(self) -> str:
|
|
138
|
+
carrier = {}
|
|
139
|
+
_propagator.inject(carrier)
|
|
140
|
+
return carrier.get("traceparent", "")
|
|
141
|
+
|
|
142
|
+
def should_record(self, tool_name: str, security_context: Optional[Dict[str, Any]] = None) -> bool:
|
|
143
|
+
if not self.enabled:
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
if self.exclude_tools and tool_name in self.exclude_tools:
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
if self.include_tools is not None and tool_name not in self.include_tools:
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
if self.tracked_users is not None:
|
|
153
|
+
auth_user = (security_context or {}).get("authenticated_user_id")
|
|
154
|
+
if not auth_user or str(auth_user) not in self.tracked_users:
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
if self.sample_rate < 1.0:
|
|
158
|
+
if random.random() > self.sample_rate:
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
return True
|
|
162
|
+
|
|
163
|
+
def _print_debug_event(self, event_dict: Dict[str, Any]):
|
|
164
|
+
"""Prints a formatted flight recorder box directly in terminal for the developer."""
|
|
165
|
+
step = event_dict.get("step_index", 1)
|
|
166
|
+
event_type = event_dict.get("event_type", "event")
|
|
167
|
+
name = event_dict.get("name", "unknown")
|
|
168
|
+
status = event_dict.get("status", "success")
|
|
169
|
+
duration = event_dict.get("duration_ms", 0)
|
|
170
|
+
traceparent = event_dict.get("traceparent", "N/A")
|
|
171
|
+
redactions = event_dict.get("redacted_fields")
|
|
172
|
+
user = (event_dict.get("security_context") or {}).get("authenticated_user_id", "N/A")
|
|
173
|
+
status_icon = "[OK]" if status == "success" else "[ERR]"
|
|
174
|
+
sep = "-" * 80
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
lines = [
|
|
178
|
+
"",
|
|
179
|
+
sep,
|
|
180
|
+
f"[QAISS] STEP {step} | {str(event_type).upper()} : '{name}' {status_icon}",
|
|
181
|
+
sep,
|
|
182
|
+
f" User ID : {user:<24} Duration : {duration} ms",
|
|
183
|
+
f" Trace ID : {traceparent}",
|
|
184
|
+
]
|
|
185
|
+
if redactions:
|
|
186
|
+
lines.append(f" Scrubbed : {redactions}")
|
|
187
|
+
if event_dict.get("input"):
|
|
188
|
+
preview = json.dumps(event_dict["input"], indent=2, ensure_ascii=True)
|
|
189
|
+
if len(preview) > 400:
|
|
190
|
+
preview = preview[:400] + "\n ...[truncated]"
|
|
191
|
+
lines.append(f" Input:\n{preview}")
|
|
192
|
+
if event_dict.get("output"):
|
|
193
|
+
preview = json.dumps(event_dict["output"], indent=2, ensure_ascii=True)
|
|
194
|
+
if len(preview) > 400:
|
|
195
|
+
preview = preview[:400] + "\n ...[truncated]"
|
|
196
|
+
lines.append(f" Output:\n{preview}")
|
|
197
|
+
lines.append(sep)
|
|
198
|
+
print("\n".join(lines))
|
|
199
|
+
except Exception as e:
|
|
200
|
+
logger.debug(f"QAISS: debug print failed: {e}")
|
|
201
|
+
|
|
202
|
+
def track_response(
|
|
203
|
+
self,
|
|
204
|
+
final_message: str,
|
|
205
|
+
user_prompt: Optional[str] = None,
|
|
206
|
+
security_context: Optional[Dict[str, Any]] = None,
|
|
207
|
+
llm_metadata: Optional[Dict[str, Any]] = None,
|
|
208
|
+
workflow_id: Optional[str] = None
|
|
209
|
+
):
|
|
210
|
+
"""Manually tracks the user-visible final chatbot response."""
|
|
211
|
+
try:
|
|
212
|
+
effective_sec_ctx = security_context or self.get_context()
|
|
213
|
+
|
|
214
|
+
if not self.should_record("chatbot_response", effective_sec_ctx):
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
step_index = self.get_next_step_index()
|
|
218
|
+
|
|
219
|
+
# PII scrubbing
|
|
220
|
+
clean_msg, msg_redactions = scrub_payload(final_message, "output.final_response")
|
|
221
|
+
clean_prompt, prompt_redactions = scrub_payload(user_prompt, "input.user_prompt") if user_prompt else (None, [])
|
|
222
|
+
all_redactions = (msg_redactions + prompt_redactions) or None
|
|
223
|
+
|
|
224
|
+
# Truncation
|
|
225
|
+
final_msg_trunc, is_msg_trunc = truncate_payload(clean_msg)
|
|
226
|
+
final_prompt_trunc, is_prompt_trunc = truncate_payload(clean_prompt) if clean_prompt else (None, False)
|
|
227
|
+
|
|
228
|
+
sec_ctx = SecurityContext(**effective_sec_ctx) if effective_sec_ctx else None
|
|
229
|
+
llm_meta = LLMMetadata(**llm_metadata) if llm_metadata else None
|
|
230
|
+
|
|
231
|
+
now_ts = format_utc_timestamp()
|
|
232
|
+
|
|
233
|
+
event = QaissEventModel(
|
|
234
|
+
event_id=f"evt_{uuid.uuid4().hex[:12]}",
|
|
235
|
+
traceparent=self.get_active_traceparent(),
|
|
236
|
+
workflow_id=workflow_id,
|
|
237
|
+
step_index=step_index,
|
|
238
|
+
event_type="final_response",
|
|
239
|
+
name="chatbot_response",
|
|
240
|
+
status="success",
|
|
241
|
+
start_timestamp=now_ts,
|
|
242
|
+
end_timestamp=now_ts,
|
|
243
|
+
duration_ms=0,
|
|
244
|
+
input={"user_prompt": final_prompt_trunc} if user_prompt else None,
|
|
245
|
+
output={"final_response": final_msg_trunc},
|
|
246
|
+
is_truncated=(is_msg_trunc or is_prompt_trunc),
|
|
247
|
+
redacted_fields=all_redactions,
|
|
248
|
+
security_context=sec_ctx,
|
|
249
|
+
llm_metadata=llm_meta,
|
|
250
|
+
tags={"environment": self.environment}
|
|
251
|
+
)
|
|
252
|
+
self.send_event(event.to_wire_dict())
|
|
253
|
+
except Exception as e:
|
|
254
|
+
logger.debug(f"QAISS: track_response failed safely: {e}")
|
|
255
|
+
|
|
256
|
+
@contextmanager
|
|
257
|
+
def trace_session(self, session_name: str = "agent_session"):
|
|
258
|
+
with self._tracer.start_as_current_span(session_name) as span:
|
|
259
|
+
yield span
|
|
260
|
+
|
|
261
|
+
def send_event(self, event_dict: Dict[str, Any]):
|
|
262
|
+
try:
|
|
263
|
+
if self.debug:
|
|
264
|
+
self._print_debug_event(event_dict)
|
|
265
|
+
self._transport.enqueue(event_dict)
|
|
266
|
+
except Exception as e:
|
|
267
|
+
logger.debug(f"QAISS: send_event failed safely: {e}")
|
|
268
|
+
|
|
269
|
+
def flush(self, timeout: float = 2.0):
|
|
270
|
+
try:
|
|
271
|
+
if hasattr(self._transport, "flush"):
|
|
272
|
+
self._transport.flush(timeout=timeout)
|
|
273
|
+
except Exception:
|
|
274
|
+
pass
|
|
275
|
+
|
|
276
|
+
def close(self, timeout: float = 2.0):
|
|
277
|
+
try:
|
|
278
|
+
if hasattr(self._transport, "flush_and_close"):
|
|
279
|
+
self._transport.flush_and_close()
|
|
280
|
+
elif hasattr(self._transport, "close"):
|
|
281
|
+
self._transport.close()
|
|
282
|
+
except Exception:
|
|
283
|
+
pass
|
qaiss/context.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from contextvars import ContextVar
|
|
2
|
+
from typing import Optional, Dict, Any, List
|
|
3
|
+
|
|
4
|
+
# Multi-client, thread-safe, async-isolated request context variables
|
|
5
|
+
_contexts_by_client: ContextVar[Dict[str, Optional[Dict[str, Any]]]] = ContextVar("qaiss_contexts_by_client", default={})
|
|
6
|
+
_step_counters_by_client: ContextVar[Dict[str, int]] = ContextVar("qaiss_step_counters_by_client", default={})
|
|
7
|
+
|
|
8
|
+
def set_context(
|
|
9
|
+
authenticated_user_id: str,
|
|
10
|
+
resource_owner_id: Optional[str] = None,
|
|
11
|
+
tenant_id: Optional[str] = None,
|
|
12
|
+
scopes_provided: Optional[List[str]] = None,
|
|
13
|
+
client_id: str = "default"
|
|
14
|
+
) -> None:
|
|
15
|
+
"""
|
|
16
|
+
Sets the request-scoped security context and resets the step counter for a specific client instance.
|
|
17
|
+
Guarantees full isolation across multiple QaissClient instances (multi-tenant or multi-service).
|
|
18
|
+
"""
|
|
19
|
+
# Copy dict for ContextVar immutability across async coroutines
|
|
20
|
+
curr_counters = dict(_step_counters_by_client.get())
|
|
21
|
+
curr_counters[client_id] = 0
|
|
22
|
+
_step_counters_by_client.set(curr_counters)
|
|
23
|
+
|
|
24
|
+
curr_contexts = dict(_contexts_by_client.get())
|
|
25
|
+
curr_contexts[client_id] = {
|
|
26
|
+
"authenticated_user_id": str(authenticated_user_id) if authenticated_user_id is not None else None,
|
|
27
|
+
"resource_owner_id": str(resource_owner_id) if resource_owner_id is not None else (str(authenticated_user_id) if authenticated_user_id is not None else None),
|
|
28
|
+
"tenant_id": str(tenant_id) if tenant_id is not None else None,
|
|
29
|
+
"scopes_provided": scopes_provided
|
|
30
|
+
}
|
|
31
|
+
_contexts_by_client.set(curr_contexts)
|
|
32
|
+
|
|
33
|
+
def get_current_security_context(client_id: str = "default") -> Optional[Dict[str, Any]]:
|
|
34
|
+
"""Retrieves the request-scoped security context for a specific client instance."""
|
|
35
|
+
contexts = _contexts_by_client.get()
|
|
36
|
+
if not contexts:
|
|
37
|
+
return None
|
|
38
|
+
if client_id in contexts and contexts[client_id]:
|
|
39
|
+
return contexts[client_id]
|
|
40
|
+
if "default" in contexts and contexts["default"]:
|
|
41
|
+
return contexts["default"]
|
|
42
|
+
|
|
43
|
+
# Fallback to single active context ONLY for un-scoped/default lookups (global library patches)
|
|
44
|
+
if client_id == "default":
|
|
45
|
+
non_empty = [c for c in contexts.values() if c]
|
|
46
|
+
if len(non_empty) == 1:
|
|
47
|
+
return non_empty[0]
|
|
48
|
+
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
def get_next_step_index(client_id: str = "default") -> int:
|
|
52
|
+
"""Increments and returns the sequential step index for the given client instance."""
|
|
53
|
+
curr_counters = dict(_step_counters_by_client.get())
|
|
54
|
+
|
|
55
|
+
# If the requested client_id is default and not yet in counters, but exactly 1 client context is active
|
|
56
|
+
target_id = client_id
|
|
57
|
+
if target_id == "default" and target_id not in curr_counters and len(curr_counters) == 1:
|
|
58
|
+
target_id = next(iter(curr_counters.keys()))
|
|
59
|
+
|
|
60
|
+
current = curr_counters.get(target_id, 0)
|
|
61
|
+
next_step = current + 1
|
|
62
|
+
curr_counters[target_id] = next_step
|
|
63
|
+
if client_id != target_id:
|
|
64
|
+
curr_counters[client_id] = next_step
|
|
65
|
+
_step_counters_by_client.set(curr_counters)
|
|
66
|
+
return next_step
|
|
67
|
+
|
|
68
|
+
def clear_context(client_id: Optional[str] = None) -> None:
|
|
69
|
+
"""Clears the security context and resets the step counter for a client (or all clients)."""
|
|
70
|
+
if client_id:
|
|
71
|
+
curr_contexts = dict(_contexts_by_client.get())
|
|
72
|
+
curr_contexts.pop(client_id, None)
|
|
73
|
+
_contexts_by_client.set(curr_contexts)
|
|
74
|
+
|
|
75
|
+
curr_counters = dict(_step_counters_by_client.get())
|
|
76
|
+
curr_counters.pop(client_id, None)
|
|
77
|
+
_step_counters_by_client.set(curr_counters)
|
|
78
|
+
else:
|
|
79
|
+
_contexts_by_client.set({})
|
|
80
|
+
_step_counters_by_client.set({})
|