veris-ai 1.0.0__py3-none-any.whl → 1.2.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.

Potentially problematic release.


This version of veris-ai might be problematic. Click here for more details.

@@ -0,0 +1,233 @@
1
+ """Synchronous Jaeger Query Service client built on **requests**.
2
+
3
+ This implementation keeps dependencies minimal while providing fully-typed
4
+ *pydantic* models for both **request** and **response** bodies.
5
+ """
6
+
7
+ import json
8
+ from typing import Any, Dict, List, Optional, Self
9
+
10
+ import requests
11
+
12
+ from .models import GetTraceResponse, SearchResponse, Span, Tag, Trace
13
+
14
+
15
+ __all__ = ["JaegerClient"]
16
+
17
+
18
+ class JaegerClient: # noqa: D101
19
+ def __init__(
20
+ self,
21
+ base_url: str,
22
+ *,
23
+ timeout: float | None = 10.0,
24
+ session: requests.Session | None = None,
25
+ headers: dict[str, str] | None = None,
26
+ ) -> None:
27
+ """Create a new *JaegerClient* instance.
28
+
29
+ Args:
30
+ base_url: Base URL of the Jaeger Query Service (e.g. ``http://localhost:16686``).
31
+ timeout: Request timeout in **seconds** (applied to every call).
32
+ session: Optional pre-configured :class:`requests.Session` to reuse.
33
+ headers: Optional default headers to send with every request.
34
+ """
35
+ # Normalise to avoid trailing slash duplicates
36
+ self._base_url = base_url.rstrip("/")
37
+ self._timeout = timeout
38
+ self._external_session = session # If provided we won't close it
39
+ self._headers = headers or {}
40
+
41
+ # ---------------------------------------------------------------------
42
+ # Internal helpers
43
+ # ---------------------------------------------------------------------
44
+
45
+ def _make_session(self) -> tuple[requests.Session, bool]: # noqa: D401
46
+ """Return a *(session, should_close)* tuple.
47
+
48
+ If an external session was supplied we **must not** close it after the
49
+ request, hence the boolean flag letting callers know whether they are
50
+ responsible for closing the session.
51
+ """
52
+ if self._external_session is not None:
53
+ return self._external_session, False
54
+
55
+ # Reuse the session opened via the context manager if available
56
+ if hasattr(self, "_session_ctx"):
57
+ return self._session_ctx, False
58
+
59
+ session = requests.Session()
60
+ session.headers.update(self._headers)
61
+ return session, True
62
+
63
+ def _span_matches_tags(self, span: Span, span_tags: Dict[str, Any]) -> bool:
64
+ """Check if a span matches any of the provided tags (OR logic)."""
65
+ if not span.tags or not span_tags:
66
+ return False
67
+
68
+ # Convert span tags to a dict for easier lookup
69
+ span_tag_dict = {tag.key: tag.value for tag in span.tags}
70
+
71
+ # OR logic: return True if ANY tag matches
72
+ for key, value in span_tags.items():
73
+ if span_tag_dict.get(key) == value:
74
+ return True
75
+
76
+ return False
77
+
78
+ def _filter_spans(
79
+ self,
80
+ traces: List[Trace],
81
+ span_tags: Optional[Dict[str, Any]],
82
+ span_operations: Optional[List[str]] = None
83
+ ) -> List[Trace]:
84
+ """
85
+ Filter spans within traces based on span_tags (OR logic) and/or span_operations (OR logic).
86
+ If both are provided, a span must match at least one tag AND at least one operation.
87
+ """
88
+ if not span_tags and not span_operations:
89
+ return traces
90
+
91
+ filtered_traces = []
92
+ for trace in traces:
93
+ filtered_spans = []
94
+ for span in trace.spans:
95
+ tag_match = True
96
+ op_match = True
97
+
98
+ if span_tags:
99
+ tag_match = self._span_matches_tags(span, span_tags)
100
+ if span_operations:
101
+ op_match = span.operationName in span_operations
102
+
103
+ # If both filters are provided, require both to match (AND logic)
104
+ if tag_match and op_match:
105
+ filtered_spans.append(span)
106
+
107
+ if filtered_spans:
108
+ filtered_trace = Trace(
109
+ traceID=trace.traceID,
110
+ spans=filtered_spans,
111
+ process=trace.process,
112
+ warnings=trace.warnings
113
+ )
114
+ filtered_traces.append(filtered_trace)
115
+
116
+ return filtered_traces
117
+
118
+ # ---------------------------------------------------------------------
119
+ # Public API
120
+ # ---------------------------------------------------------------------
121
+
122
+ def search(
123
+ self,
124
+ service: Optional[str] = None,
125
+ *,
126
+ limit: Optional[int] = None,
127
+ tags: Optional[Dict[str, Any]] = None,
128
+ operation: Optional[str] = None,
129
+ span_tags: Optional[Dict[str, Any]] = None,
130
+ span_operations: Optional[List[str]] = None,
131
+ **kwargs: Any
132
+ ) -> SearchResponse: # noqa: D401
133
+ """Search traces using the *v1* ``/api/traces`` endpoint with optional span filtering.
134
+
135
+ Args:
136
+ service: Service name to search for. If not provided, searches across all services.
137
+ limit: Maximum number of traces to return.
138
+ tags: Dictionary of tag filters for trace-level filtering (AND-combined).
139
+ operation: Operation name to search for.
140
+ span_tags: Dictionary of tag filters for span-level filtering (OR-combined AND-combined with span_operations).
141
+ Applied client-side after retrieving traces.
142
+ span_operations: List of operation names to search for (OR-combined AND-combined with span_tags).
143
+ **kwargs: Additional parameters to pass to the Jaeger API.
144
+
145
+ Returns:
146
+ Parsed :class:`~veris_ai.jaeger_interface.models.SearchResponse` model
147
+ with spans filtered according to span_tags if provided.
148
+ """
149
+ # Build params for the Jaeger API (excluding span_tags)
150
+ params: Dict[str, Any] = {}
151
+
152
+ if service is not None:
153
+ params["service"] = service
154
+
155
+ if limit is not None:
156
+ params["limit"] = limit
157
+
158
+ if operation is not None:
159
+ params["operation"] = operation
160
+
161
+ if tags:
162
+ # Convert tags to JSON string as expected by Jaeger API
163
+ params["tags"] = json.dumps(tags)
164
+
165
+ # Add any additional parameters
166
+ params.update(kwargs)
167
+
168
+ session, should_close = self._make_session()
169
+ try:
170
+ url = f"{self._base_url}/api/traces"
171
+ response = session.get(url, params=params, timeout=self._timeout)
172
+ response.raise_for_status()
173
+ data = response.json()
174
+ finally:
175
+ if should_close:
176
+ session.close()
177
+
178
+ # Parse the response
179
+ search_response = SearchResponse.model_validate(data) # type: ignore[arg-type]
180
+
181
+ # Apply span-level filtering if span_tags is provided
182
+ if span_tags or span_operations and search_response.data and isinstance(search_response.data, list):
183
+ filtered_traces = self._filter_spans(search_response.data, span_tags, span_operations)
184
+ search_response.data = filtered_traces
185
+ # Update the total to reflect filtered results
186
+ if search_response.total is not None:
187
+ search_response.total = len(filtered_traces)
188
+
189
+ return search_response
190
+
191
+ def get_trace(self, trace_id: str) -> GetTraceResponse: # noqa: D401
192
+ """Retrieve a single trace by *trace_id*.
193
+
194
+ Args:
195
+ trace_id: The Jaeger trace identifier.
196
+
197
+ Returns:
198
+ Parsed :class:`~veris_ai.jaeger_interface.models.GetTraceResponse` model.
199
+ """
200
+ if not trace_id:
201
+ error_msg = "trace_id must be non-empty"
202
+ raise ValueError(error_msg)
203
+
204
+ session, should_close = self._make_session()
205
+ try:
206
+ url = f"{self._base_url}/api/traces/{trace_id}"
207
+ response = session.get(url, timeout=self._timeout)
208
+ response.raise_for_status()
209
+ data = response.json()
210
+ finally:
211
+ if should_close:
212
+ session.close()
213
+ return GetTraceResponse.model_validate(data) # type: ignore[arg-type]
214
+
215
+ # ------------------------------------------------------------------
216
+ # Context-manager helpers (optional but convenient)
217
+ # ------------------------------------------------------------------
218
+
219
+ def __enter__(self) -> Self:
220
+ """Enter the context manager."""
221
+ self._session_ctx, self._should_close_ctx = self._make_session()
222
+ return self
223
+
224
+ def __exit__(
225
+ self,
226
+ exc_type: type[BaseException] | None,
227
+ exc: BaseException | None,
228
+ tb: Any | None,
229
+ ) -> None:
230
+ """Exit the context manager."""
231
+ # Only close if we created the session
232
+ if getattr(self, "_should_close_ctx", False):
233
+ self._session_ctx.close()
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel, Field, ConfigDict
7
+
8
+ __all__ = [
9
+ "Tag",
10
+ "Process",
11
+ "Span",
12
+ "Trace",
13
+ "SearchResponse",
14
+ "GetTraceResponse",
15
+ ]
16
+
17
+
18
+ class Tag(BaseModel):
19
+ """A Jaeger tag key/value pair."""
20
+
21
+ key: str
22
+ value: Any
23
+ type: str | None = None # Jaeger uses an optional *type* field in v1
24
+
25
+
26
+ class Process(BaseModel):
27
+ """Represents the *process* section of a Jaeger trace."""
28
+
29
+ serviceName: str = Field(alias="serviceName") # noqa: N815
30
+ tags: list[Tag] | None = None
31
+
32
+
33
+ class Span(BaseModel):
34
+ """Represents a single Jaeger span."""
35
+
36
+ traceID: str # noqa: N815
37
+ spanID: str # noqa: N815
38
+ operationName: str # noqa: N815
39
+ startTime: int # noqa: N815
40
+ duration: int
41
+ tags: list[Tag] | None = None
42
+ references: list[dict[str, Any]] | None = None
43
+ processID: str | None = None # noqa: N815
44
+
45
+ model_config = ConfigDict(extra="allow")
46
+
47
+
48
+ class Trace(BaseModel):
49
+ """A full Jaeger trace as returned by the Query API."""
50
+
51
+ traceID: str # noqa: N815
52
+ spans: list[Span]
53
+ process: Process | dict[str, Process] | None = None
54
+ warnings: list[str] | None = None
55
+
56
+ model_config = ConfigDict(extra="allow")
57
+
58
+
59
+ class _BaseResponse(BaseModel):
60
+ data: list[Trace] | Trace | None = None
61
+ errors: list[str] | None = None
62
+
63
+ # Allow any additional keys returned by Jaeger so that nothing gets
64
+ # silently dropped if the backend adds new fields we don't know about.
65
+
66
+ model_config = ConfigDict(extra="allow")
67
+
68
+
69
+ class SearchResponse(_BaseResponse):
70
+ """Response model for *search* or *find traces* requests."""
71
+
72
+ total: int | None = None
73
+ limit: int | None = None
74
+
75
+
76
+ class GetTraceResponse(_BaseResponse):
77
+ """Response model for *get trace by id* requests."""
78
+
79
+ # Same as base but alias for clarity
veris_ai/models.py ADDED
@@ -0,0 +1,11 @@
1
+ """Models for the VERIS SDK."""
2
+
3
+ from enum import Enum
4
+
5
+
6
+ class ResponseExpectation(str, Enum):
7
+ """Expected response behavior for tool mocking."""
8
+
9
+ AUTO = "auto"
10
+ REQUIRED = "required"
11
+ NONE = "none"
veris_ai/tool_mock.py CHANGED
@@ -8,13 +8,15 @@ from contextvars import ContextVar
8
8
  from functools import wraps
9
9
  from typing import (
10
10
  Any,
11
+ Literal,
11
12
  TypeVar,
12
13
  get_type_hints,
13
14
  )
14
15
 
15
16
  import httpx
16
17
 
17
- from veris_ai.utils import convert_to_type
18
+ from veris_ai.models import ResponseExpectation
19
+ from veris_ai.utils import convert_to_type, extract_json_schema
18
20
 
19
21
  logger = logging.getLogger(__name__)
20
22
 
@@ -90,72 +92,168 @@ class VerisSDK:
90
92
  **params_dict,
91
93
  )
92
94
 
93
- def mock(self, func: Callable) -> Callable:
95
+ def mock(
96
+ self,
97
+ mode: Literal["tool", "function"] = "tool",
98
+ expects_response: bool | None = None,
99
+ cache_response: bool | None = None,
100
+ ) -> Callable:
94
101
  """Decorator for mocking tool calls."""
95
- endpoint = os.getenv("VERIS_MOCK_ENDPOINT_URL")
96
- if not endpoint:
97
- error_msg = "VERIS_MOCK_ENDPOINT_URL environment variable is not set"
98
- raise ValueError(error_msg)
99
- # Default timeout of 30 seconds
100
- timeout = float(os.getenv("VERIS_MOCK_TIMEOUT", "30.0"))
101
-
102
- @wraps(func)
103
- async def wrapper(
104
- *args: tuple[object, ...],
105
- **kwargs: dict[str, object],
106
- ) -> object:
107
- # Check if we're in simulation mode
108
- env_mode = os.getenv("ENV", "").lower()
109
- if env_mode != "simulation":
110
- # If not in simulation mode, execute the original function
111
- return await func(*args, **kwargs)
112
- logger.info(f"Simulating function: {func.__name__}")
113
- sig = inspect.signature(func)
114
- type_hints = get_type_hints(func)
115
-
116
- # Extract return type object (not just the name)
117
- return_type_obj = type_hints.pop("return", Any)
118
-
119
- # Create parameter info
120
- params_info = {}
121
- bound_args = sig.bind(*args, **kwargs)
122
- bound_args.apply_defaults()
123
-
124
- for param_name, param_value in bound_args.arguments.items():
125
- params_info[param_name] = {
126
- "value": param_value,
127
- "type": type_hints.get(param_name, Any).__name__,
102
+
103
+ def decorator(func: Callable) -> Callable:
104
+ """Decorator for mocking tool calls."""
105
+ endpoint = os.getenv("VERIS_MOCK_ENDPOINT_URL")
106
+ if not endpoint:
107
+ error_msg = "VERIS_MOCK_ENDPOINT_URL environment variable is not set"
108
+ raise ValueError(error_msg)
109
+ # Default timeout of 30 seconds
110
+ timeout = float(os.getenv("VERIS_MOCK_TIMEOUT", "90.0"))
111
+
112
+ # Check if the original function is async
113
+ is_async = inspect.iscoroutinefunction(func)
114
+
115
+ def create_mock_payload(
116
+ *args: tuple[object, ...],
117
+ **kwargs: dict[str, object],
118
+ ) -> tuple[dict[str, Any], Any]:
119
+ """Create the mock payload - shared logic for both sync and async."""
120
+ sig = inspect.signature(func)
121
+ type_hints = get_type_hints(func)
122
+
123
+ # Extract return type object (not just the name)
124
+ return_type_obj = type_hints.pop("return", Any)
125
+ # Create parameter info
126
+ params_info = {}
127
+ bound_args = sig.bind(*args, **kwargs)
128
+ bound_args.apply_defaults()
129
+ _ = bound_args.arguments.pop("ctx", None)
130
+ _ = bound_args.arguments.pop("self", None)
131
+ _ = bound_args.arguments.pop("cls", None)
132
+
133
+ for param_name, param_value in bound_args.arguments.items():
134
+ params_info[param_name] = {
135
+ "value": str(param_value),
136
+ "type": str(type_hints.get(param_name, Any)),
137
+ }
138
+ # Get function docstring
139
+ docstring = inspect.getdoc(func) or ""
140
+ nonlocal expects_response
141
+ if expects_response is None and mode == "function":
142
+ expects_response = False
143
+ # Prepare payload
144
+ # Convert expects_response to response_expectation enum
145
+ if expects_response is False:
146
+ response_expectation = ResponseExpectation.NONE
147
+ elif expects_response is True:
148
+ response_expectation = ResponseExpectation.REQUIRED
149
+ else:
150
+ response_expectation = ResponseExpectation.AUTO
151
+
152
+ payload = {
153
+ "session_id": self.session_id,
154
+ "response_expectation": response_expectation.value,
155
+ "cache_response": bool(cache_response) if cache_response is not None else False,
156
+ "tool_call": {
157
+ "function_name": func.__name__,
158
+ "parameters": params_info,
159
+ "return_type": json.dumps(extract_json_schema(return_type_obj)),
160
+ "docstring": docstring,
161
+ },
128
162
  }
129
163
 
130
- # Get function docstring
131
- docstring = inspect.getdoc(func) or ""
132
- # Prepare payload
133
- payload = {
134
- "session_id": self.session_id,
135
- "tool_call": {
136
- "function_name": func.__name__,
137
- "parameters": params_info,
138
- "return_type": return_type_obj.__name__,
139
- "docstring": docstring,
140
- },
141
- }
142
-
143
- # Send request to endpoint with timeout
144
- async with httpx.AsyncClient(timeout=timeout) as client:
145
- response = await client.post(endpoint, json=payload)
146
- response.raise_for_status()
147
- mock_result = response.json()["result"]
148
- logger.info(f"Mock response: {mock_result}")
149
-
150
- # Parse the mock result if it's a string
151
- if isinstance(mock_result, str):
152
- with suppress(json.JSONDecodeError):
153
- mock_result = json.loads(mock_result)
154
-
155
- # Convert the mock result to the expected return type
156
- return convert_to_type(mock_result, return_type_obj)
157
-
158
- return wrapper
164
+ return payload, return_type_obj
165
+
166
+ @wraps(func)
167
+ async def async_wrapper(
168
+ *args: tuple[object, ...],
169
+ **kwargs: dict[str, object],
170
+ ) -> object:
171
+ # Check if we're in simulation mode
172
+ env_mode = os.getenv("ENV", "").lower()
173
+ if env_mode != "simulation":
174
+ # If not in simulation mode, execute the original function
175
+ return await func(*args, **kwargs)
176
+
177
+ logger.info(f"Simulating function: {func.__name__}")
178
+ payload, return_type_obj = create_mock_payload(*args, **kwargs)
179
+
180
+ # Send request to endpoint with timeout
181
+ async with httpx.AsyncClient(timeout=timeout) as client:
182
+ response = await client.post(endpoint, json=payload)
183
+ response.raise_for_status()
184
+ mock_result = response.json()
185
+ logger.info(f"Mock response: {mock_result}")
186
+
187
+ if isinstance(mock_result, str):
188
+ with suppress(json.JSONDecodeError):
189
+ mock_result = json.loads(mock_result)
190
+ return convert_to_type(mock_result, return_type_obj)
191
+ return convert_to_type(mock_result, return_type_obj)
192
+
193
+ @wraps(func)
194
+ def sync_wrapper(
195
+ *args: tuple[object, ...],
196
+ **kwargs: dict[str, object],
197
+ ) -> object:
198
+ # Check if we're in simulation mode
199
+ env_mode = os.getenv("ENV", "").lower()
200
+ if env_mode != "simulation":
201
+ # If not in simulation mode, execute the original function
202
+ return func(*args, **kwargs)
203
+
204
+ logger.info(f"Simulating function: {func.__name__}")
205
+ payload, return_type_obj = create_mock_payload(*args, **kwargs)
206
+
207
+ # Send request to endpoint with timeout (synchronous)
208
+ with httpx.Client(timeout=timeout) as client:
209
+ response = client.post(endpoint, json=payload)
210
+ response.raise_for_status()
211
+ mock_result = response.json()
212
+ logger.info(f"Mock response: {mock_result}")
213
+
214
+ if isinstance(mock_result, str):
215
+ with suppress(json.JSONDecodeError):
216
+ mock_result = json.loads(mock_result)
217
+ return convert_to_type(mock_result, return_type_obj)
218
+ return convert_to_type(mock_result, return_type_obj)
219
+
220
+ # Return the appropriate wrapper based on whether the function is async
221
+ return async_wrapper if is_async else sync_wrapper
222
+
223
+ return decorator
224
+
225
+ def stub(self, return_value: Any) -> Callable: # noqa: ANN401
226
+ """Decorator for stubbing toolw calls."""
227
+
228
+ def decorator(func: Callable) -> Callable:
229
+ # Check if the original function is async
230
+ is_async = inspect.iscoroutinefunction(func)
231
+
232
+ @wraps(func)
233
+ async def async_wrapper(
234
+ *args: tuple[object, ...],
235
+ **kwargs: dict[str, object],
236
+ ) -> object:
237
+ env_mode = os.getenv("ENV", "").lower()
238
+ if env_mode != "simulation":
239
+ # If not in simulation mode, execute the original function
240
+ return func(*args, **kwargs)
241
+ logger.info(f"Simulating function: {func.__name__}")
242
+ return return_value
243
+
244
+ @wraps(func)
245
+ def sync_wrapper(*args: tuple[object, ...], **kwargs: dict[str, object]) -> object:
246
+ env_mode = os.getenv("ENV", "").lower()
247
+ if env_mode != "simulation":
248
+ # If not in simulation mode, execute the original function
249
+ return func(*args, **kwargs)
250
+ logger.info(f"Simulating function: {func.__name__}")
251
+ return return_value
252
+
253
+ # Return the appropriate wrapper based on whether the function is async
254
+ return async_wrapper if is_async else sync_wrapper
255
+
256
+ return decorator
159
257
 
160
258
 
161
259
  veris = VerisSDK()