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 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({})