veris-ai 1.1.0__tar.gz → 1.2.0__tar.gz

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.

Files changed (33) hide show
  1. {veris_ai-1.1.0 → veris_ai-1.2.0}/PKG-INFO +14 -5
  2. {veris_ai-1.1.0 → veris_ai-1.2.0}/README.md +13 -4
  3. {veris_ai-1.1.0 → veris_ai-1.2.0}/pyproject.toml +1 -1
  4. {veris_ai-1.1.0 → veris_ai-1.2.0}/src/veris_ai/__init__.py +3 -2
  5. veris_ai-1.2.0/src/veris_ai/jaeger_interface/README.md +137 -0
  6. veris_ai-1.2.0/src/veris_ai/jaeger_interface/__init__.py +39 -0
  7. veris_ai-1.2.0/src/veris_ai/jaeger_interface/client.py +233 -0
  8. veris_ai-1.2.0/src/veris_ai/jaeger_interface/models.py +79 -0
  9. veris_ai-1.2.0/src/veris_ai/models.py +11 -0
  10. {veris_ai-1.1.0 → veris_ai-1.2.0}/src/veris_ai/tool_mock.py +82 -22
  11. {veris_ai-1.1.0 → veris_ai-1.2.0}/src/veris_ai/utils.py +1 -0
  12. {veris_ai-1.1.0 → veris_ai-1.2.0}/tests/test_utils.py +22 -0
  13. {veris_ai-1.1.0 → veris_ai-1.2.0}/uv.lock +2 -2
  14. veris_ai-1.1.0/src/veris_ai/jaeger_interface/README.md +0 -109
  15. veris_ai-1.1.0/src/veris_ai/jaeger_interface/__init__.py +0 -26
  16. veris_ai-1.1.0/src/veris_ai/jaeger_interface/client.py +0 -133
  17. veris_ai-1.1.0/src/veris_ai/jaeger_interface/models.py +0 -153
  18. {veris_ai-1.1.0 → veris_ai-1.2.0}/.github/workflows/release.yml +0 -0
  19. {veris_ai-1.1.0 → veris_ai-1.2.0}/.github/workflows/test.yml +0 -0
  20. {veris_ai-1.1.0 → veris_ai-1.2.0}/.gitignore +0 -0
  21. {veris_ai-1.1.0 → veris_ai-1.2.0}/CHANGELOG.md +0 -0
  22. {veris_ai-1.1.0 → veris_ai-1.2.0}/CLAUDE.md +0 -0
  23. {veris_ai-1.1.0 → veris_ai-1.2.0}/LICENSE +0 -0
  24. {veris_ai-1.1.0 → veris_ai-1.2.0}/examples/__init__.py +0 -0
  25. {veris_ai-1.1.0 → veris_ai-1.2.0}/examples/import_options.py +0 -0
  26. {veris_ai-1.1.0 → veris_ai-1.2.0}/src/veris_ai/braintrust_tracing.py +0 -0
  27. {veris_ai-1.1.0 → veris_ai-1.2.0}/tests/__init__.py +0 -0
  28. {veris_ai-1.1.0 → veris_ai-1.2.0}/tests/conftest.py +0 -0
  29. {veris_ai-1.1.0 → veris_ai-1.2.0}/tests/fixtures/__init__.py +0 -0
  30. {veris_ai-1.1.0 → veris_ai-1.2.0}/tests/fixtures/simple_app.py +0 -0
  31. {veris_ai-1.1.0 → veris_ai-1.2.0}/tests/fixtures/sse_server.py +0 -0
  32. {veris_ai-1.1.0 → veris_ai-1.2.0}/tests/test_mcp_protocol_server_mocked.py +0 -0
  33. {veris_ai-1.1.0 → veris_ai-1.2.0}/tests/test_tool_mock.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: veris-ai
3
- Version: 1.1.0
3
+ Version: 1.2.0
4
4
  Summary: A Python package for Veris AI tools
5
5
  Project-URL: Homepage, https://github.com/veris-ai/veris-python-sdk
6
6
  Project-URL: Bug Tracker, https://github.com/veris-ai/veris-python-sdk/issues
@@ -64,7 +64,7 @@ The SDK supports flexible import patterns to minimize dependencies:
64
64
 
65
65
  ```python
66
66
  # These imports only require base dependencies (httpx, pydantic, requests)
67
- from veris_ai import veris, JaegerClient, SearchQuery
67
+ from veris_ai import veris, JaegerClient
68
68
  ```
69
69
 
70
70
  ### Optional Imports (Require Extra Dependencies)
@@ -434,14 +434,23 @@ This project is licensed under the MIT License - see the LICENSE file for detail
434
434
  ## Jaeger Trace Interface
435
435
 
436
436
  A lightweight, fully-typed wrapper around the Jaeger **Query Service** HTTP API lives under `veris_ai.jaeger_interface`.
437
- It allows you to **search and retrieve traces** from both Jaegers default storage back-ends as well as **OpenSearch**, which powers the official Jaeger UI search page.
437
+ It allows you to **search and retrieve traces** from both Jaeger's default storage back-ends as well as **OpenSearch**, with additional **client-side span filtering** capabilities.
438
438
 
439
439
  ```python
440
- from veris_ai.jaeger_interface import JaegerClient, SearchQuery
440
+ from veris_ai.jaeger_interface import JaegerClient
441
441
 
442
442
  client = JaegerClient("http://localhost:16686")
443
443
 
444
- traces = client.search(SearchQuery(service="veris-agent", limit=2))
444
+ # Search with trace-level filters (server-side)
445
+ traces = client.search(service="veris-agent", limit=2, tags={"error": "true"})
446
+
447
+ # Search with span-level filters (client-side, OR logic)
448
+ filtered = client.search(
449
+ service="veris-agent",
450
+ limit=10,
451
+ span_tags={"http.status_code": 500, "db.error": "timeout"}
452
+ )
453
+
445
454
  first_trace = client.get_trace(traces.data[0].traceID)
446
455
  ```
447
456
 
@@ -28,7 +28,7 @@ The SDK supports flexible import patterns to minimize dependencies:
28
28
 
29
29
  ```python
30
30
  # These imports only require base dependencies (httpx, pydantic, requests)
31
- from veris_ai import veris, JaegerClient, SearchQuery
31
+ from veris_ai import veris, JaegerClient
32
32
  ```
33
33
 
34
34
  ### Optional Imports (Require Extra Dependencies)
@@ -398,14 +398,23 @@ This project is licensed under the MIT License - see the LICENSE file for detail
398
398
  ## Jaeger Trace Interface
399
399
 
400
400
  A lightweight, fully-typed wrapper around the Jaeger **Query Service** HTTP API lives under `veris_ai.jaeger_interface`.
401
- It allows you to **search and retrieve traces** from both Jaegers default storage back-ends as well as **OpenSearch**, which powers the official Jaeger UI search page.
401
+ It allows you to **search and retrieve traces** from both Jaeger's default storage back-ends as well as **OpenSearch**, with additional **client-side span filtering** capabilities.
402
402
 
403
403
  ```python
404
- from veris_ai.jaeger_interface import JaegerClient, SearchQuery
404
+ from veris_ai.jaeger_interface import JaegerClient
405
405
 
406
406
  client = JaegerClient("http://localhost:16686")
407
407
 
408
- traces = client.search(SearchQuery(service="veris-agent", limit=2))
408
+ # Search with trace-level filters (server-side)
409
+ traces = client.search(service="veris-agent", limit=2, tags={"error": "true"})
410
+
411
+ # Search with span-level filters (client-side, OR logic)
412
+ filtered = client.search(
413
+ service="veris-agent",
414
+ limit=10,
415
+ span_tags={"http.status_code": 500, "db.error": "timeout"}
416
+ )
417
+
409
418
  first_trace = client.get_trace(traces.data[0].traceID)
410
419
  ```
411
420
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "veris-ai"
7
- version = "1.1.0"
7
+ version = "1.2.0"
8
8
  description = "A Python package for Veris AI tools"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -5,8 +5,9 @@ from typing import Any
5
5
  __version__ = "0.1.0"
6
6
 
7
7
  # Import lightweight modules that only use base dependencies
8
- from .jaeger_interface import JaegerClient, SearchQuery
9
8
  from .tool_mock import veris
9
+ from .jaeger_interface import JaegerClient
10
+ from .models import ResponseExpectation
10
11
 
11
12
  # Lazy import for modules with heavy dependencies
12
13
  _instrument = None
@@ -36,6 +37,6 @@ def instrument(*args: Any, **kwargs: Any) -> Any: # noqa: ANN401
36
37
  __all__ = [
37
38
  "veris",
38
39
  "JaegerClient",
39
- "SearchQuery",
40
40
  "instrument",
41
+ "ResponseExpectation"
41
42
  ]
@@ -0,0 +1,137 @@
1
+ # Jaeger Interface
2
+
3
+ This sub-package ships a **thin synchronous wrapper** around the
4
+ [Jaeger Query Service](https://www.jaegertracing.io/docs/) HTTP API so
5
+ that you can **search for and retrieve traces** directly from Python
6
+ with minimal boilerplate. It also provides **client-side span filtering**
7
+ capabilities for more granular control over the returned data.
8
+
9
+ > The client relies on `requests` (already included in the SDK's
10
+ > dependencies) and uses *pydantic* for full type-safety.
11
+
12
+ ---
13
+
14
+ ## Installation
15
+
16
+ `veris-ai` already lists both `requests` and `pydantic` as hard
17
+ requirements, so **no additional dependencies are required**.
18
+
19
+ ```bash
20
+ pip install veris-ai
21
+ ```
22
+
23
+ ---
24
+
25
+ ## Quick-start
26
+
27
+ ```python
28
+ from veris_ai.jaeger_interface import JaegerClient
29
+ import json
30
+ from veris_ai.jaeger_interface.models import Trace
31
+ # Replace with the URL of your Jaeger Query Service instance
32
+ client = JaegerClient("http://localhost:16686")
33
+
34
+ # --- 1. Search traces --------------------------------------------------
35
+ resp = client.search(
36
+ service="veris-agent",
37
+ limit=10,
38
+ # operation="CustomSpanData",
39
+ tags={"veris.session_id":"088b5aaf-84bd-4768-9a62-5e981222a9f2"},
40
+ span_tags={"bt.metadata.model":"gpt-4.1-2025-04-14"}
41
+ )
42
+
43
+ # save to json
44
+ with open("resp.json", "w") as f:
45
+ f.write(resp.model_dump_json(indent=2))
46
+
47
+ # Guard clause
48
+ if not resp or not resp.data:
49
+ print("No data found")
50
+ exit(1)
51
+
52
+ # Print trace ids
53
+ for trace in resp.data:
54
+ if isinstance(trace, Trace):
55
+ print("TRACE ID:", trace.traceID, len(trace.spans), "spans")
56
+
57
+ # --- 2. Retrieve a specific trace -------------------------------------
58
+ if isinstance(resp.data, list):
59
+ trace_id = resp.data[0].traceID
60
+ else:
61
+ trace_id = resp.data.traceID
62
+
63
+ detailed = client.get_trace(trace_id)
64
+ # save detailed to json
65
+ with open("detailed.json", "w") as f:
66
+ f.write(detailed.model_dump_json(indent=2))
67
+ ```
68
+
69
+ ---
70
+
71
+ ## API Reference
72
+
73
+ ### `JaegerClient`
74
+
75
+ | Method | Description |
76
+ | -------- | ----------- |
77
+ | `search(service, *, limit=None, tags=None, operation=None, span_tags=None, **kwargs) -> SearchResponse` | Search for traces with optional span-level filtering. |
78
+ | `get_trace(trace_id: str) -> GetTraceResponse` | Fetch a single trace by ID (wrapper around `/api/traces/{id}`). |
79
+
80
+ ### `search()` Parameters
81
+
82
+ The `search()` method now uses a flattened parameter structure:
83
+
84
+ | Parameter | Type | Description |
85
+ | --------- | ---- | ----------- |
86
+ | `service` | `str` | Service name to search for. Optional - if not provided, searches across all services. |
87
+ | `limit` | `int` | Maximum number of traces to return. |
88
+ | `tags` | `Dict[str, Any]` | Trace-level tag filters (AND logic). A trace must have a span matching ALL tags. |
89
+ | `operation` | `str` | Filter by operation name. |
90
+ | `span_tags` | `Dict[str, Any]` | Span-level tag filters (OR logic). Returns only spans matching ANY of these tags. |
91
+ | `span_operations` | `List[str]` | Span-level operation name filters (OR logic). Returns only spans matching ANY of these operations. |
92
+ | `**kwargs` | `Any` | Additional parameters passed directly to Jaeger API. |
93
+
94
+ ### Filter Logic
95
+
96
+ The interface provides two levels of filtering:
97
+
98
+ 1. **Trace-level filtering** (`tags` parameter):
99
+ - Sent directly to Jaeger API
100
+ - Uses AND logic: all tag key-value pairs must match on a single span
101
+ - Efficient server-side filtering
102
+
103
+ 2. **Span-level filtering** (`span_tags` parameter):
104
+ - Applied client-side after retrieving traces
105
+ - Uses OR logic: spans matching ANY of the provided tags are included
106
+ - Traces with no matching spans are excluded from results
107
+ - Useful for finding spans with specific characteristics across different traces
108
+
109
+ ### Example: Combining Filters
110
+
111
+ ```python
112
+ # Find traces in service that have errors, then filter to show only
113
+ # spans with specific HTTP status codes or database errors
114
+ traces = client.search(
115
+ service="my-api",
116
+ tags={"error": "true"}, # Trace must contain an error
117
+ span_tags={
118
+ "http.status_code": 500,
119
+ "http.status_code": 503,
120
+ "db.error": "connection_timeout"
121
+ } # Show only spans with these specific errors
122
+ )
123
+ ```
124
+
125
+ ---
126
+
127
+ ## Compatibility
128
+
129
+ The implementation targets **Jaeger v1.x** REST endpoints. For clusters
130
+ backed by **OpenSearch** storage the same endpoints apply. Should you
131
+ need API v3 support feel free to open an issue or contribution—thanks!
132
+
133
+ ---
134
+
135
+ ## License
136
+
137
+ This package is released under the **MIT license**.
@@ -0,0 +1,39 @@
1
+ """Jaeger interface for searching and retrieving traces.
2
+
3
+ This sub-package provides a thin synchronous wrapper around the Jaeger
4
+ Query Service HTTP API with client-side span filtering capabilities.
5
+
6
+ Typical usage example::
7
+
8
+ from veris_ai.jaeger_interface import JaegerClient
9
+
10
+ client = JaegerClient("http://localhost:16686")
11
+
12
+ # Search traces with trace-level filters
13
+ traces = client.search(
14
+ service="veris-agent",
15
+ limit=20,
16
+ tags={"error": "true"} # AND logic at trace level
17
+ )
18
+
19
+ # Search with span-level filtering
20
+ traces_filtered = client.search(
21
+ service="veris-agent",
22
+ limit=20,
23
+ span_tags={
24
+ "http.status_code": 404,
25
+ "db.error": "timeout"
26
+ } # OR logic: spans with either tag are included
27
+ )
28
+
29
+ # Get a specific trace
30
+ trace = client.get_trace(traces.data[0].traceID)
31
+
32
+ The implementation uses *requests* under the hood and all public functions
33
+ are fully typed using *pydantic* models so that IDEs can provide proper
34
+ autocomplete and type checking.
35
+ """
36
+
37
+ from .client import JaegerClient as JaegerClient # noqa: F401
38
+
39
+ __all__ = ["JaegerClient"]
@@ -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
@@ -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"
@@ -15,6 +15,7 @@ from typing import (
15
15
 
16
16
  import httpx
17
17
 
18
+ from veris_ai.models import ResponseExpectation
18
19
  from veris_ai.utils import convert_to_type, extract_json_schema
19
20
 
20
21
  logger = logging.getLogger(__name__)
@@ -107,18 +108,15 @@ class VerisSDK:
107
108
  raise ValueError(error_msg)
108
109
  # Default timeout of 30 seconds
109
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)
110
114
 
111
- @wraps(func)
112
- async def wrapper(
115
+ def create_mock_payload(
113
116
  *args: tuple[object, ...],
114
117
  **kwargs: dict[str, object],
115
- ) -> object:
116
- # Check if we're in simulation mode
117
- env_mode = os.getenv("ENV", "").lower()
118
- if env_mode != "simulation":
119
- # If not in simulation mode, execute the original function
120
- return await func(*args, **kwargs)
121
- logger.info(f"Simulating function: {func.__name__}")
118
+ ) -> tuple[dict[str, Any], Any]:
119
+ """Create the mock payload - shared logic for both sync and async."""
122
120
  sig = inspect.signature(func)
123
121
  type_hints = get_type_hints(func)
124
122
 
@@ -143,10 +141,18 @@ class VerisSDK:
143
141
  if expects_response is None and mode == "function":
144
142
  expects_response = False
145
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
+
146
152
  payload = {
147
153
  "session_id": self.session_id,
148
- "expects_response": expects_response,
149
- "cache_response": cache_response,
154
+ "response_expectation": response_expectation.value,
155
+ "cache_response": bool(cache_response) if cache_response is not None else False,
150
156
  "tool_call": {
151
157
  "function_name": func.__name__,
152
158
  "parameters": params_info,
@@ -155,6 +161,22 @@ class VerisSDK:
155
161
  },
156
162
  }
157
163
 
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
+
158
180
  # Send request to endpoint with timeout
159
181
  async with httpx.AsyncClient(timeout=timeout) as client:
160
182
  response = await client.post(endpoint, json=payload)
@@ -162,36 +184,74 @@ class VerisSDK:
162
184
  mock_result = response.json()
163
185
  logger.info(f"Mock response: {mock_result}")
164
186
 
165
- # Convert the mock result to the expected return type
166
- if mode == "tool":
167
- return {"content": [{"type": "text", "text": mock_result}]}
168
- # Parse the mock result if it's a string
169
- # Extract result field for backwards compatibility
170
- # Parse the mock result if it's a string
171
187
  if isinstance(mock_result, str):
172
188
  with suppress(json.JSONDecodeError):
173
189
  mock_result = json.loads(mock_result)
174
190
  return convert_to_type(mock_result, return_type_obj)
175
191
  return convert_to_type(mock_result, return_type_obj)
176
192
 
177
- return wrapper
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
178
222
 
179
223
  return decorator
180
224
 
181
225
  def stub(self, return_value: Any) -> Callable: # noqa: ANN401
182
- """Decorator for stubbing tool calls."""
226
+ """Decorator for stubbing toolw calls."""
183
227
 
184
228
  def decorator(func: Callable) -> Callable:
229
+ # Check if the original function is async
230
+ is_async = inspect.iscoroutinefunction(func)
231
+
185
232
  @wraps(func)
186
- async def wrapper(*args: tuple[object, ...], **kwargs: dict[str, object]) -> object:
233
+ async def async_wrapper(
234
+ *args: tuple[object, ...],
235
+ **kwargs: dict[str, object],
236
+ ) -> object:
187
237
  env_mode = os.getenv("ENV", "").lower()
188
238
  if env_mode != "simulation":
189
239
  # If not in simulation mode, execute the original function
190
- return await func(*args, **kwargs)
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)
191
250
  logger.info(f"Simulating function: {func.__name__}")
192
251
  return return_value
193
252
 
194
- return wrapper
253
+ # Return the appropriate wrapper based on whether the function is async
254
+ return async_wrapper if is_async else sync_wrapper
195
255
 
196
256
  return decorator
197
257
 
@@ -20,6 +20,7 @@ def convert_to_type(value: object, target_type: type) -> object:
20
20
  list: _convert_list,
21
21
  dict: _convert_dict,
22
22
  Union: _convert_union,
23
+ types.UnionType: _convert_union, # Handle Python 3.10+ union syntax (str | int)
23
24
  }
24
25
 
25
26
  # Use appropriate converter based on origin
@@ -108,6 +108,28 @@ class TestConvertToType:
108
108
  result = convert_to_type(value, list[dict[str, int]])
109
109
  assert result == [{"a": 1}, {"b": 2}]
110
110
 
111
+ def test_convert_to_type_union_python310_syntax(self):
112
+ """Test conversion with Python 3.10+ union syntax (str | int)."""
113
+ # Test with string value that can be converted to int
114
+ result = convert_to_type("42", str | int)
115
+ assert result == "42" # String type is tried first and succeeds
116
+ assert isinstance(result, str)
117
+
118
+ # Test with int value - will be converted to string (first type in union)
119
+ result = convert_to_type(42, str | int)
120
+ assert result == "42" # Converted to string since str is first in union
121
+ assert isinstance(result, str)
122
+
123
+ # Test with int | str (reversed order) - int comes first
124
+ result = convert_to_type("42", int | str)
125
+ assert result == 42 # Converted to int since int is first
126
+ assert isinstance(result, int)
127
+
128
+ # Test with a value that only works as string
129
+ result = convert_to_type("hello", str | int | float)
130
+ assert result == "hello"
131
+ assert isinstance(result, str)
132
+
111
133
 
112
134
  class TestExtractJsonSchema:
113
135
  """Test the extract_json_schema function."""
@@ -1,5 +1,5 @@
1
1
  version = 1
2
- revision = 2
2
+ revision = 3
3
3
  requires-python = ">=3.11"
4
4
  resolution-markers = [
5
5
  "python_full_version >= '3.13'",
@@ -1472,7 +1472,7 @@ wheels = [
1472
1472
 
1473
1473
  [[package]]
1474
1474
  name = "veris-ai"
1475
- version = "1.0.0"
1475
+ version = "1.1.0"
1476
1476
  source = { editable = "." }
1477
1477
  dependencies = [
1478
1478
  { name = "httpx" },
@@ -1,109 +0,0 @@
1
- # Jaeger Interface
2
-
3
- This sub-package ships a **thin synchronous wrapper** around the
4
- [Jaeger Query Service](https://www.jaegertracing.io/docs/) HTTP API so
5
- that you can **search for and retrieve traces** directly from Python
6
- with minimal boilerplate.
7
-
8
- > The client relies on `requests` (already included in the SDK’s
9
- > dependencies) and uses *pydantic* for full type-safety.
10
-
11
- ---
12
-
13
- ## Installation
14
-
15
- `veris-ai` already lists both `requests` and `pydantic` as hard
16
- requirements, so **no additional dependencies are required**.
17
-
18
- ```bash
19
- pip install veris-ai
20
- ```
21
-
22
- ---
23
-
24
- ## Quick-start
25
-
26
- ```python
27
- from veris_ai.jaeger_interface import JaegerClient, SearchQuery
28
- import json
29
- from veris_ai.jaeger_interface.models import Trace
30
- # Replace with the URL of your Jaeger Query Service instance
31
- client = JaegerClient("http://localhost:16686")
32
-
33
- # --- 1. Search traces --------------------------------------------------
34
- resp = client.search(
35
- SearchQuery(
36
- service="veris-agent",
37
- limit=3,
38
- operation="CustomSpanData",
39
- tags={"bt.metadata.session_id":"oRL1_IhMP3s7T7mYrixCW"}
40
- )
41
- )
42
-
43
- # save to json
44
- with open("resp.json", "w") as f:
45
- f.write(resp.model_dump_json(indent=2))
46
-
47
- # Guard clause
48
- if not resp or not resp.data:
49
- print("No data found")
50
- exit(1)
51
-
52
- # Print trace ids
53
- for trace in resp.data:
54
- if isinstance(trace, Trace):
55
- print("TRACE ID:", trace.traceID, len(trace.spans), "spans")
56
-
57
- # --- 2. Retrieve a specific trace -------------------------------------
58
- if isinstance(resp.data, list):
59
- trace_id = resp.data[0].traceID
60
- else:
61
- trace_id = resp.data.traceID
62
-
63
- detailed = client.get_trace(trace_id)
64
- # save detailed to json
65
- with open("detailed.json", "w") as f:
66
- f.write(detailed.model_dump_json(indent=2))
67
- ```
68
-
69
- ---
70
-
71
- ## API Reference
72
-
73
- ### `JaegerClient`
74
-
75
- | Method | Description |
76
- | -------- | ----------- |
77
- | `search(query: SearchQuery) -> SearchResponse` | Search for traces matching the given parameters (wrapper around `/api/traces`). |
78
- | `get_trace(trace_id: str) -> GetTraceResponse` | Fetch a single trace by ID (wrapper around `/api/traces/{id}`). |
79
-
80
- ### `SearchQuery`
81
-
82
- `SearchQuery` is a *pydantic* model for building the query-string sent to
83
- Jaeger’s `/api/traces` endpoint.
84
-
85
- Parameter logic:
86
-
87
- * **service** – mandatory, sets the top-level service filter.
88
- * **operation** – optional; a trace is kept only if *any* of its spans has
89
- this exact `operationName`.
90
- * **tags** – dict of key/value filters; *all* pairs must match on a single
91
- span (logical AND). Example: `{"error": "true"}`.
92
- * **limit** – applied after all filters and trims the result list.
93
-
94
- Any other fields are forwarded untouched, so you can experiment with new
95
- Jaeger parameters without waiting for an SDK update.
96
-
97
- ---
98
-
99
- ## Compatibility
100
-
101
- The implementation targets **Jaeger v1.x** REST endpoints. For clusters
102
- backed by **OpenSearch** storage the same endpoints apply. Should you
103
- need API v3 support feel free to open an issue or contribution—thanks!
104
-
105
- ---
106
-
107
- ## Licence
108
-
109
- This package is released under the **MIT license**.
@@ -1,26 +0,0 @@
1
- """Jaeger interface for searching and retrieving traces.
2
-
3
- This sub-package provides a thin synchronous wrapper around the Jaeger
4
- Query Service HTTP API.
5
-
6
- Typical usage example::
7
-
8
- from veris_ai.jaeger_interface import JaegerClient, SearchQuery
9
-
10
- client = JaegerClient("http://localhost:16686")
11
-
12
- traces = client.search(
13
- SearchQuery(service="veris-agent", limit=20)
14
- )
15
-
16
- trace = client.get_trace(traces.data[0].traceID)
17
-
18
- The implementation uses *requests* under the hood and all public functions
19
- are fully typed using *pydantic* models so that IDEs can provide proper
20
- autocomplete and type checking.
21
- """
22
-
23
- from .client import JaegerClient
24
- from .models import SearchQuery
25
-
26
- __all__ = ["JaegerClient", "SearchQuery"]
@@ -1,133 +0,0 @@
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
- from __future__ import annotations
8
-
9
- from typing import TYPE_CHECKING, Self
10
-
11
- import requests
12
-
13
- from .models import GetTraceResponse, SearchQuery, SearchResponse
14
-
15
- if TYPE_CHECKING:
16
- from types import TracebackType
17
-
18
- __all__ = ["JaegerClient"]
19
-
20
-
21
- class JaegerClient: # noqa: D101
22
- def __init__(
23
- self,
24
- base_url: str,
25
- *,
26
- timeout: float | None = 10.0,
27
- session: requests.Session | None = None,
28
- headers: dict[str, str] | None = None,
29
- ) -> None:
30
- """Create a new *JaegerClient* instance.
31
-
32
- Args:
33
- base_url: Base URL of the Jaeger Query Service (e.g. ``http://localhost:16686``).
34
- timeout: Request timeout in **seconds** (applied to every call).
35
- session: Optional pre-configured :class:`requests.Session` to reuse.
36
- headers: Optional default headers to send with every request.
37
- """
38
- # Normalise to avoid trailing slash duplicates
39
- self._base_url = base_url.rstrip("/")
40
- self._timeout = timeout
41
- self._external_session = session # If provided we won't close it
42
- self._headers = headers or {}
43
-
44
- # ---------------------------------------------------------------------
45
- # Internal helpers
46
- # ---------------------------------------------------------------------
47
-
48
- def _make_session(self) -> tuple[requests.Session, bool]: # noqa: D401
49
- """Return a *(session, should_close)* tuple.
50
-
51
- If an external session was supplied we **must not** close it after the
52
- request, hence the boolean flag letting callers know whether they are
53
- responsible for closing the session.
54
- """
55
- if self._external_session is not None:
56
- return self._external_session, False
57
-
58
- # Reuse the session opened via the context manager if available
59
- if hasattr(self, "_session_ctx"):
60
- return self._session_ctx, False
61
-
62
- session = requests.Session()
63
- session.headers.update(self._headers)
64
- return session, True
65
-
66
- # ---------------------------------------------------------------------
67
- # Public API
68
- # ---------------------------------------------------------------------
69
-
70
- def search(self, query: SearchQuery) -> SearchResponse: # noqa: D401
71
- """Search traces using the *v1* ``/api/traces`` endpoint.
72
-
73
- Args:
74
- query: :class:`~veris_ai.jaeger_interface.models.SearchQuery` instance.
75
-
76
- Returns:
77
- Parsed :class:`~veris_ai.jaeger_interface.models.SearchResponse` model.
78
- """
79
- params = query.to_params()
80
- session, should_close = self._make_session()
81
- try:
82
- url = f"{self._base_url}/api/traces"
83
- response = session.get(url, params=params, timeout=self._timeout)
84
- response.raise_for_status()
85
- data = response.json()
86
- finally:
87
- if should_close:
88
- session.close()
89
- return SearchResponse.model_validate(data) # type: ignore[arg-type]
90
-
91
- def get_trace(self, trace_id: str) -> GetTraceResponse: # noqa: D401
92
- """Retrieve a single trace by *trace_id*.
93
-
94
- Args:
95
- trace_id: The Jaeger trace identifier.
96
-
97
- Returns:
98
- Parsed :class:`~veris_ai.jaeger_interface.models.GetTraceResponse` model.
99
- """
100
- if not trace_id:
101
- error_msg = "trace_id must be non-empty"
102
- raise ValueError(error_msg)
103
-
104
- session, should_close = self._make_session()
105
- try:
106
- url = f"{self._base_url}/api/traces/{trace_id}"
107
- response = session.get(url, timeout=self._timeout)
108
- response.raise_for_status()
109
- data = response.json()
110
- finally:
111
- if should_close:
112
- session.close()
113
- return GetTraceResponse.model_validate(data) # type: ignore[arg-type]
114
-
115
- # ------------------------------------------------------------------
116
- # Context-manager helpers (optional but convenient)
117
- # ------------------------------------------------------------------
118
-
119
- def __enter__(self) -> Self:
120
- """Enter the context manager."""
121
- self._session_ctx, self._should_close_ctx = self._make_session()
122
- return self
123
-
124
- def __exit__(
125
- self,
126
- exc_type: type[BaseException] | None,
127
- exc: BaseException | None,
128
- tb: TracebackType | None,
129
- ) -> None:
130
- """Exit the context manager."""
131
- # Only close if we created the session
132
- if getattr(self, "_should_close_ctx", False):
133
- self._session_ctx.close()
@@ -1,153 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import json
4
- from typing import Any
5
-
6
- from pydantic import BaseModel, ConfigDict, Field, field_validator
7
-
8
- __all__ = [
9
- "Tag",
10
- "Process",
11
- "Span",
12
- "Trace",
13
- "SearchResponse",
14
- "GetTraceResponse",
15
- "SearchQuery",
16
- ]
17
-
18
-
19
- class Tag(BaseModel):
20
- """A Jaeger tag key/value pair."""
21
-
22
- key: str
23
- value: Any
24
- type: str | None = None # Jaeger uses an optional *type* field in v1
25
-
26
-
27
- class Process(BaseModel):
28
- """Represents the *process* section of a Jaeger trace."""
29
-
30
- serviceName: str = Field(alias="serviceName") # noqa: N815
31
- tags: list[Tag] | None = None
32
-
33
-
34
- class Span(BaseModel):
35
- """Represents a single Jaeger span."""
36
-
37
- traceID: str # noqa: N815
38
- spanID: str # noqa: N815
39
- operationName: str # noqa: N815
40
- startTime: int # noqa: N815
41
- duration: int
42
- tags: list[Tag] | None = None
43
- references: list[dict[str, Any]] | None = None
44
- processID: str | None = None # noqa: N815
45
-
46
- model_config = ConfigDict(extra="allow")
47
-
48
-
49
- class Trace(BaseModel):
50
- """A full Jaeger trace as returned by the Query API."""
51
-
52
- traceID: str # noqa: N815
53
- spans: list[Span]
54
- process: Process | dict[str, Process] | None = None
55
- warnings: list[str] | None = None
56
-
57
- model_config = ConfigDict(extra="allow")
58
-
59
-
60
- class _BaseResponse(BaseModel):
61
- data: list[Trace] | Trace | None = None
62
- errors: list[str] | None = None
63
-
64
- # Allow any additional keys returned by Jaeger so that nothing gets
65
- # silently dropped if the backend adds new fields we don’t know about.
66
-
67
- model_config = ConfigDict(extra="allow")
68
-
69
-
70
- class SearchResponse(_BaseResponse):
71
- """Response model for *search* or *find traces* requests."""
72
-
73
- total: int | None = None
74
- limit: int | None = None
75
-
76
-
77
- class GetTraceResponse(_BaseResponse):
78
- """Response model for *get trace by id* requests."""
79
-
80
- # Same as base but alias for clarity
81
-
82
-
83
- # ---------------------------------------------------------------------------
84
- # Query models
85
- # ---------------------------------------------------------------------------
86
-
87
-
88
- class SearchQuery(BaseModel):
89
- """Minimal set of query parameters for the `/api/traces` endpoint.
90
-
91
- Parameter interaction rules:
92
-
93
- * **service** – global filter; *all* returned traces must belong to this
94
- service.
95
- * **operation** – optional secondary filter; returned traces must contain
96
- *at least one span* whose ``operationName`` equals the provided value.
97
- * **tags** – dictionary of key‒value pairs; each trace must include a span
98
- that matches **all** of the pairs (logical AND).
99
- * **limit** – applied *after* all other filters; truncates the final list
100
- of traces to the requested maximum.
101
-
102
- Any additional/unknown parameters are forwarded thanks to
103
- ``extra = "allow"`` – this keeps the model future-proof.
104
- """
105
-
106
- # NOTE: Only the fields that are reliably supported by Jaeger’s REST API and
107
- # work with the user’s deployment are kept. The model remains *open* to any
108
- # extra parameters thanks to `extra = "allow"`.
109
-
110
- service: str = Field(
111
- ...,
112
- description="Service name to search for. Example: 'veris-agent'",
113
- )
114
-
115
- limit: int | None = Field(
116
- None,
117
- description="Maximum number of traces to return. Example: 10",
118
- )
119
-
120
- tags: dict[str, Any] | None = Field(
121
- None,
122
- description=(
123
- "Dictionary of tag filters (AND-combined). "
124
- "Example: {'error': 'true', 'bt.metrics.time_to_first_token': '0.813544'}"
125
- ),
126
- )
127
-
128
- operation: str | None = Field(
129
- None,
130
- description="Operation name to search for. Example: 'process_chat_message'",
131
- )
132
-
133
- model_config = ConfigDict(
134
- extra="allow", # allow additional query params implicitly
135
- populate_by_name=True,
136
- str_to_lower=False,
137
- )
138
-
139
- @field_validator("tags", mode="before")
140
- @classmethod
141
- def _empty_to_none(cls, v: dict[str, Any] | None) -> dict[str, Any] | None: # noqa: D401, ANN102
142
- return v or None
143
-
144
- def to_params(self) -> dict[str, Any]: # noqa: D401
145
- """Translate the model into a *requests*/*httpx* compatible params dict."""
146
- # Dump using aliases so ``span_kind`` becomes ``spanKind`` automatically.
147
- params: dict[str, Any] = self.model_dump(exclude_none=True, by_alias=True)
148
-
149
- # Convert tags to a JSON string if necessary – this matches what the UI sends.
150
- if "tags" in params and isinstance(params["tags"], dict):
151
- params["tags"] = json.dumps(params["tags"])
152
-
153
- return params
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes