xtrace-sdk 0.0.3__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.
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: xtrace-sdk
3
+ Version: 0.0.3
4
+ Summary: XTrace Python SDK
5
+ Requires-Python: >=3.9
6
+ Requires-Dist: requests>=2.31
@@ -0,0 +1,15 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "xtrace-sdk"
7
+ version = "0.0.3"
8
+ description = "XTrace Python SDK"
9
+ requires-python = ">=3.9"
10
+ dependencies = [
11
+ "requests>=2.31",
12
+ ]
13
+
14
+ [tool.setuptools]
15
+ packages = ["xtrace_sdk"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ from .client import XTraceClient
2
+ from .openai import observe_openai
3
+
4
+ __all__ = ["XTraceClient", "observe_openai"]
@@ -0,0 +1,316 @@
1
+ import atexit
2
+ import json
3
+ import os
4
+ import queue
5
+ import threading
6
+ import time
7
+ import uuid
8
+ from dataclasses import dataclass
9
+ from datetime import datetime, timezone
10
+ from typing import Any, Dict, List, Optional, Union
11
+
12
+ import requests
13
+
14
+
15
+ @dataclass
16
+ class XTraceConfig:
17
+ base_url: str
18
+ api_key: str
19
+ default_project_id: str
20
+ queue_max_size: int = 10_000
21
+ batch_max_size: int = 100
22
+ flush_interval_s: float = 0.5
23
+ request_timeout_s: float = 2.0
24
+ max_retries: int = 3
25
+
26
+
27
+ class XTraceClient:
28
+ def __init__(
29
+ self,
30
+ base_url: Optional[str] = None,
31
+ api_key: Optional[str] = None,
32
+ default_project_id: Optional[str] = None,
33
+ *,
34
+ queue_max_size: int = 10_000,
35
+ batch_max_size: int = 100,
36
+ flush_interval_s: float = 0.5,
37
+ request_timeout_s: float = 2.0,
38
+ max_retries: int = 3,
39
+ ) -> None:
40
+ base_url = base_url or os.environ.get("XTRACE_BASE_URL") or "http://127.0.0.1:8742"
41
+ api_key = api_key or os.environ.get("XTRACE_API_KEY") or os.environ.get("XTRACE_BEARER_TOKEN")
42
+ if not api_key:
43
+ raise ValueError("missing api_key (env XTRACE_API_KEY or XTRACE_BEARER_TOKEN)")
44
+
45
+ default_project_id = default_project_id or os.environ.get("XTRACE_PROJECT_ID") or "default"
46
+
47
+ self._cfg = XTraceConfig(
48
+ base_url=base_url.rstrip("/"),
49
+ api_key=api_key,
50
+ default_project_id=default_project_id,
51
+ queue_max_size=queue_max_size,
52
+ batch_max_size=batch_max_size,
53
+ flush_interval_s=flush_interval_s,
54
+ request_timeout_s=request_timeout_s,
55
+ max_retries=max_retries,
56
+ )
57
+
58
+ self._q: "queue.Queue[Dict[str, Any]]" = queue.Queue(maxsize=self._cfg.queue_max_size)
59
+ self._stop = threading.Event()
60
+ self._worker = threading.Thread(target=self._run, name="xtrace-ingest", daemon=True)
61
+
62
+ self.dropped_events = 0
63
+ self.sent_batches = 0
64
+ self.failed_batches = 0
65
+
66
+ self._worker.start()
67
+ atexit.register(self.shutdown, timeout_s=1.0)
68
+
69
+ @staticmethod
70
+ def new_id() -> str:
71
+ return str(uuid.uuid4())
72
+
73
+ def enqueue_batch(self, payload: Dict[str, Any]) -> None:
74
+ try:
75
+ self._q.put_nowait(payload)
76
+ except queue.Full:
77
+ self.dropped_events += 1
78
+
79
+ def flush(self, timeout_s: float = 5.0) -> None:
80
+ deadline = time.time() + timeout_s
81
+ while time.time() < deadline:
82
+ if self._q.empty():
83
+ return
84
+ time.sleep(0.05)
85
+
86
+ def shutdown(self, timeout_s: float = 1.0) -> None:
87
+ if self._stop.is_set():
88
+ return
89
+ self._stop.set()
90
+ self.flush(timeout_s=timeout_s)
91
+
92
+ def _run(self) -> None:
93
+ session = requests.Session()
94
+ url = f"{self._cfg.base_url}/v1/l/batch"
95
+ headers = {
96
+ "Authorization": f"Bearer {self._cfg.api_key}",
97
+ "Content-Type": "application/json",
98
+ }
99
+
100
+ buf: List[Dict[str, Any]] = []
101
+ last_flush = time.time()
102
+
103
+ while not self._stop.is_set():
104
+ timeout = max(0.0, self._cfg.flush_interval_s - (time.time() - last_flush))
105
+ try:
106
+ item = self._q.get(timeout=timeout)
107
+ buf.append(item)
108
+ except queue.Empty:
109
+ pass
110
+
111
+ if not buf:
112
+ continue
113
+
114
+ if len(buf) < self._cfg.batch_max_size and (time.time() - last_flush) < self._cfg.flush_interval_s:
115
+ continue
116
+
117
+ payloads = self._split_by_trace(buf)
118
+ buf.clear()
119
+ last_flush = time.time()
120
+
121
+ for payload in payloads:
122
+ ok = self._post_with_retry(session, url, headers, payload)
123
+ if ok:
124
+ self.sent_batches += 1
125
+ else:
126
+ self.failed_batches += 1
127
+
128
+ # best-effort drain
129
+ drain: List[Dict[str, Any]] = []
130
+ while True:
131
+ try:
132
+ drain.append(self._q.get_nowait())
133
+ except queue.Empty:
134
+ break
135
+ if drain:
136
+ for payload in self._split_by_trace(drain):
137
+ ok = self._post_with_retry(session, url, headers, payload)
138
+ if ok:
139
+ self.sent_batches += 1
140
+ else:
141
+ self.failed_batches += 1
142
+
143
+ def _split_by_trace(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
144
+ grouped: Dict[str, Dict[str, Any]] = {}
145
+
146
+ for it in items:
147
+ trace = it.get("trace")
148
+ observations = it.get("observations") or []
149
+
150
+ trace_id = None
151
+ if isinstance(trace, dict):
152
+ trace_id = trace.get("id")
153
+ if trace_id is None and observations:
154
+ first = observations[0]
155
+ if isinstance(first, dict):
156
+ trace_id = first.get("traceId")
157
+
158
+ if trace_id is None:
159
+ continue
160
+
161
+ if trace_id not in grouped:
162
+ grouped[trace_id] = {"trace": None, "observations": []}
163
+
164
+ if grouped[trace_id]["trace"] is None and trace is not None:
165
+ grouped[trace_id]["trace"] = trace
166
+
167
+ grouped[trace_id]["observations"].extend(observations)
168
+
169
+ return list(grouped.values())
170
+
171
+ def _post_with_retry(
172
+ self,
173
+ session: requests.Session,
174
+ url: str,
175
+ headers: Dict[str, str],
176
+ payload: Dict[str, Any],
177
+ ) -> bool:
178
+ delay = 0.2
179
+ for attempt in range(self._cfg.max_retries + 1):
180
+ try:
181
+ resp = session.post(url, headers=headers, json=payload, timeout=self._cfg.request_timeout_s)
182
+ if 200 <= resp.status_code < 300:
183
+ return True
184
+ if resp.status_code == 429 or 500 <= resp.status_code < 600:
185
+ time.sleep(delay)
186
+ delay = min(delay * 2.0, 5.0)
187
+ continue
188
+ return False
189
+ except requests.RequestException:
190
+ if attempt >= self._cfg.max_retries:
191
+ return False
192
+ time.sleep(delay)
193
+ delay = min(delay * 2.0, 5.0)
194
+ return False
195
+
196
+ def push_metrics(self, metrics: List[Dict[str, Any]]) -> Dict[str, Any]:
197
+ url = f"{self._cfg.base_url}/v1/metrics/batch"
198
+ headers = {
199
+ "Authorization": f"Bearer {self._cfg.api_key}",
200
+ "Content-Type": "application/json",
201
+ }
202
+ payload = {"metrics": [self._normalize_metric_point(m) for m in metrics]}
203
+
204
+ delay = 0.2
205
+ with requests.Session() as session:
206
+ for attempt in range(self._cfg.max_retries + 1):
207
+ try:
208
+ resp = session.post(
209
+ url,
210
+ headers=headers,
211
+ json=payload,
212
+ timeout=self._cfg.request_timeout_s,
213
+ )
214
+ if 200 <= resp.status_code < 300:
215
+ return resp.json()
216
+ if resp.status_code == 429 or 500 <= resp.status_code < 600:
217
+ if attempt >= self._cfg.max_retries:
218
+ break
219
+ time.sleep(delay)
220
+ delay = min(delay * 2.0, 5.0)
221
+ continue
222
+ resp.raise_for_status()
223
+ except requests.RequestException:
224
+ if attempt >= self._cfg.max_retries:
225
+ break
226
+ time.sleep(delay)
227
+ delay = min(delay * 2.0, 5.0)
228
+
229
+ raise RuntimeError("push_metrics failed")
230
+
231
+ def metrics_names(self) -> List[str]:
232
+ url = f"{self._cfg.base_url}/api/public/metrics/names"
233
+ headers = {
234
+ "Authorization": f"Bearer {self._cfg.api_key}",
235
+ "Content-Type": "application/json",
236
+ }
237
+
238
+ with requests.Session() as session:
239
+ resp = session.get(url, headers=headers, timeout=self._cfg.request_timeout_s)
240
+ resp.raise_for_status()
241
+ data = resp.json()
242
+
243
+ names = data.get("data") if isinstance(data, dict) else None
244
+ if not isinstance(names, list):
245
+ raise RuntimeError("invalid metrics_names response")
246
+ return names
247
+
248
+ def metrics_query(
249
+ self,
250
+ *,
251
+ name: str,
252
+ from_ts: Optional[Union[str, datetime]] = None,
253
+ to_ts: Optional[Union[str, datetime]] = None,
254
+ labels: Optional[Dict[str, str]] = None,
255
+ step: Optional[str] = None,
256
+ agg: Optional[str] = None,
257
+ ) -> Dict[str, Any]:
258
+ url = f"{self._cfg.base_url}/api/public/metrics/query"
259
+ headers = {
260
+ "Authorization": f"Bearer {self._cfg.api_key}",
261
+ "Content-Type": "application/json",
262
+ }
263
+
264
+ params: Dict[str, str] = {"name": name}
265
+ if from_ts is not None:
266
+ params["from"] = self._normalize_ts(from_ts)
267
+ if to_ts is not None:
268
+ params["to"] = self._normalize_ts(to_ts)
269
+ if labels is not None:
270
+ params["labels"] = json.dumps(labels, separators=(",", ":"), ensure_ascii=False)
271
+ if step is not None:
272
+ params["step"] = step
273
+ if agg is not None:
274
+ params["agg"] = agg
275
+
276
+ with requests.Session() as session:
277
+ resp = session.get(url, headers=headers, params=params, timeout=self._cfg.request_timeout_s)
278
+ resp.raise_for_status()
279
+ data = resp.json()
280
+
281
+ if not isinstance(data, dict) or "data" not in data:
282
+ raise RuntimeError("invalid metrics_query response")
283
+ return data
284
+
285
+ @staticmethod
286
+ def _normalize_ts(ts: Union[str, datetime]) -> str:
287
+ if isinstance(ts, str):
288
+ return ts
289
+ if ts.tzinfo is None:
290
+ ts = ts.replace(tzinfo=timezone.utc)
291
+ return ts.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
292
+
293
+ def _normalize_metric_point(self, m: Dict[str, Any]) -> Dict[str, Any]:
294
+ name = m.get("name")
295
+ value = m.get("value")
296
+ timestamp = m.get("timestamp")
297
+
298
+ if not isinstance(name, str) or not name:
299
+ raise ValueError("metric.name must be non-empty string")
300
+ if not isinstance(value, (int, float)):
301
+ raise ValueError("metric.value must be number")
302
+ if timestamp is None:
303
+ raise ValueError("metric.timestamp is required")
304
+
305
+ labels = m.get("labels")
306
+ if labels is None:
307
+ labels = {}
308
+ if not isinstance(labels, dict):
309
+ raise ValueError("metric.labels must be dict")
310
+
311
+ return {
312
+ "name": name,
313
+ "labels": {str(k): str(v) for k, v in labels.items()},
314
+ "value": float(value),
315
+ "timestamp": self._normalize_ts(timestamp),
316
+ }
@@ -0,0 +1,364 @@
1
+ import time
2
+ from typing import Any, Dict, Iterable, Optional
3
+
4
+ from .client import XTraceClient
5
+
6
+
7
+ def observe_openai(
8
+ client: Any,
9
+ *,
10
+ xtrace: XTraceClient,
11
+ name: Optional[str] = None,
12
+ user_id: Optional[str] = None,
13
+ session_id: Optional[str] = None,
14
+ tags: Optional[list[str]] = None,
15
+ metadata: Optional[dict] = None,
16
+ project_id: Optional[str] = None,
17
+ ) -> Any:
18
+ return _OpenAIClientWrapper(
19
+ client,
20
+ xtrace=xtrace,
21
+ name=name,
22
+ user_id=user_id,
23
+ session_id=session_id,
24
+ tags=tags or [],
25
+ metadata=metadata or {},
26
+ project_id=project_id,
27
+ )
28
+
29
+
30
+ class _OpenAIClientWrapper:
31
+ def __init__(
32
+ self,
33
+ client: Any,
34
+ *,
35
+ xtrace: XTraceClient,
36
+ name: Optional[str],
37
+ user_id: Optional[str],
38
+ session_id: Optional[str],
39
+ tags: list[str],
40
+ metadata: dict,
41
+ project_id: Optional[str],
42
+ ) -> None:
43
+ self._client = client
44
+ self._xtrace = xtrace
45
+ self._name = name
46
+ self._user_id = user_id
47
+ self._session_id = session_id
48
+ self._tags = tags
49
+ self._metadata = metadata
50
+ self._project_id = project_id
51
+
52
+ @property
53
+ def chat(self) -> Any:
54
+ return _ChatWrapper(self)
55
+
56
+ def __getattr__(self, item: str) -> Any:
57
+ return getattr(self._client, item)
58
+
59
+
60
+ class _ChatWrapper:
61
+ def __init__(self, root: _OpenAIClientWrapper) -> None:
62
+ self._root = root
63
+
64
+ @property
65
+ def completions(self) -> Any:
66
+ return _ChatCompletionsWrapper(self._root)
67
+
68
+
69
+ class _ChatCompletionsWrapper:
70
+ def __init__(self, root: _OpenAIClientWrapper) -> None:
71
+ self._root = root
72
+
73
+ def create(self, *args: Any, **kwargs: Any) -> Any:
74
+ trace_id = self._root._xtrace.new_id()
75
+ obs_id = self._root._xtrace.new_id()
76
+
77
+ start = time.time()
78
+ stream = bool(kwargs.get("stream", False))
79
+
80
+ result = self._root._client.chat.completions.create(*args, **kwargs)
81
+
82
+ if stream:
83
+ return _StreamWrapper(
84
+ result,
85
+ root=self._root,
86
+ trace_id=trace_id,
87
+ obs_id=obs_id,
88
+ start=start,
89
+ kwargs=kwargs,
90
+ )
91
+
92
+ latency = time.time() - start
93
+ _record_non_stream(
94
+ root=self._root,
95
+ trace_id=trace_id,
96
+ obs_id=obs_id,
97
+ start=start,
98
+ latency=latency,
99
+ response=result,
100
+ kwargs=kwargs,
101
+ )
102
+ return result
103
+
104
+
105
+ class _StreamWrapper:
106
+ def __init__(
107
+ self,
108
+ inner: Iterable[Any],
109
+ *,
110
+ root: _OpenAIClientWrapper,
111
+ trace_id: str,
112
+ obs_id: str,
113
+ start: float,
114
+ kwargs: Dict[str, Any],
115
+ ) -> None:
116
+ self._inner = iter(inner)
117
+ self._root = root
118
+ self._trace_id = trace_id
119
+ self._obs_id = obs_id
120
+ self._start = start
121
+ self._kwargs = kwargs
122
+ self._ttfb: Optional[float] = None
123
+ self._output_parts: list[str] = []
124
+ self._usage: Optional[Dict[str, Any]] = None
125
+
126
+ def __iter__(self):
127
+ return self
128
+
129
+ def __next__(self):
130
+ try:
131
+ chunk = next(self._inner)
132
+ except StopIteration:
133
+ self._finalize()
134
+ raise
135
+ else:
136
+ delta = _extract_stream_delta_text(chunk)
137
+ if delta:
138
+ if self._ttfb is None:
139
+ self._ttfb = time.time() - self._start
140
+ self._output_parts.append(delta)
141
+
142
+ usage = _extract_stream_usage(chunk)
143
+ if usage is not None:
144
+ self._usage = usage
145
+ return chunk
146
+
147
+ def close(self) -> None:
148
+ self._finalize()
149
+
150
+ def _finalize(self) -> None:
151
+ latency = time.time() - self._start
152
+ output_text = "".join(self._output_parts) if self._output_parts else None
153
+
154
+ payload = _build_payload(
155
+ root=self._root,
156
+ trace_id=self._trace_id,
157
+ obs_id=self._obs_id,
158
+ start=self._start,
159
+ latency=latency,
160
+ ttfb=self._ttfb,
161
+ output_text=output_text,
162
+ usage=self._usage,
163
+ kwargs=self._kwargs,
164
+ )
165
+ self._root._xtrace.enqueue_batch(payload)
166
+
167
+
168
+ def _record_non_stream(
169
+ *,
170
+ root: _OpenAIClientWrapper,
171
+ trace_id: str,
172
+ obs_id: str,
173
+ start: float,
174
+ latency: float,
175
+ response: Any,
176
+ kwargs: Dict[str, Any],
177
+ ) -> None:
178
+ usage = _extract_usage(response)
179
+ output_text = _extract_output_text(response)
180
+
181
+ payload = _build_payload(
182
+ root=root,
183
+ trace_id=trace_id,
184
+ obs_id=obs_id,
185
+ start=start,
186
+ latency=latency,
187
+ ttfb=None,
188
+ output_text=output_text,
189
+ usage=usage,
190
+ kwargs=kwargs,
191
+ )
192
+ root._xtrace.enqueue_batch(payload)
193
+
194
+
195
+ def _build_payload(
196
+ *,
197
+ root: _OpenAIClientWrapper,
198
+ trace_id: str,
199
+ obs_id: str,
200
+ start: float,
201
+ latency: float,
202
+ ttfb: Optional[float],
203
+ output_text: Optional[str],
204
+ usage: Optional[Dict[str, Any]],
205
+ kwargs: Dict[str, Any],
206
+ ) -> Dict[str, Any]:
207
+ ts_iso = _to_iso(start)
208
+ end_iso = _to_iso(start + latency)
209
+ completion_iso = _to_iso(start + ttfb) if ttfb is not None else None
210
+
211
+ trace = {
212
+ "id": trace_id,
213
+ "timestamp": ts_iso,
214
+ "name": kwargs.get("name") or root._name,
215
+ "userId": root._user_id,
216
+ "sessionId": root._session_id,
217
+ "tags": root._tags,
218
+ "metadata": root._metadata or None,
219
+ "projectId": root._project_id or root._xtrace._cfg.default_project_id,
220
+ "latency": latency,
221
+ "totalCost": None,
222
+ }
223
+
224
+ messages = kwargs.get("messages")
225
+ model = kwargs.get("model")
226
+
227
+ obs = {
228
+ "id": obs_id,
229
+ "traceId": trace_id,
230
+ "type": "GENERATION",
231
+ "name": "chat",
232
+ "startTime": ts_iso,
233
+ "endTime": end_iso,
234
+ "completionStartTime": completion_iso,
235
+ "model": model,
236
+ "modelParameters": None,
237
+ "input": messages,
238
+ "output": output_text,
239
+ "usage": usage,
240
+ "level": "DEFAULT",
241
+ "statusMessage": None,
242
+ "parentObservationId": None,
243
+ "promptId": None,
244
+ "promptName": None,
245
+ "promptVersion": None,
246
+ "modelId": None,
247
+ "inputPrice": None,
248
+ "outputPrice": None,
249
+ "totalPrice": None,
250
+ "calculatedInputCost": None,
251
+ "calculatedOutputCost": None,
252
+ "calculatedTotalCost": None,
253
+ "latency": latency,
254
+ "timeToFirstToken": ttfb,
255
+ "completionTokens": _safe_get_int(usage, ["output"]),
256
+ "promptTokens": _safe_get_int(usage, ["input"]),
257
+ "totalTokens": _safe_get_int(usage, ["total"]),
258
+ "unit": _safe_get_str(usage, ["unit"]),
259
+ "metadata": kwargs.get("metadata"),
260
+ "projectId": root._project_id or root._xtrace._cfg.default_project_id,
261
+ }
262
+
263
+ return {"trace": trace, "observations": [obs]}
264
+
265
+
266
+ def _to_iso(ts: float) -> str:
267
+ return time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(ts)) + f".{int((ts % 1) * 1_000_000):06d}Z"
268
+
269
+
270
+ def _extract_output_text(resp: Any) -> Optional[str]:
271
+ try:
272
+ choice0 = resp.choices[0]
273
+ msg = getattr(choice0, "message", None)
274
+ if msg is not None:
275
+ return getattr(msg, "content", None)
276
+ except Exception:
277
+ return None
278
+
279
+
280
+ def _extract_stream_usage(chunk: Any) -> Optional[Dict[str, Any]]:
281
+ # Best-effort: depends on provider and whether stream_options.include_usage is enabled.
282
+ try:
283
+ u = getattr(chunk, "usage", None)
284
+ if u is not None:
285
+ prompt = getattr(u, "prompt_tokens", None)
286
+ completion = getattr(u, "completion_tokens", None)
287
+ total = getattr(u, "total_tokens", None)
288
+ if prompt is None and completion is None and total is None:
289
+ return None
290
+ return {
291
+ "input": int(prompt or 0),
292
+ "output": int(completion or 0),
293
+ "total": int(total or ((prompt or 0) + (completion or 0))),
294
+ "unit": "TOKENS",
295
+ }
296
+
297
+ # allow custom injected dict
298
+ u2 = getattr(chunk, "xtrace_usage", None)
299
+ if isinstance(u2, dict):
300
+ return u2
301
+ return None
302
+ except Exception:
303
+ return None
304
+ return None
305
+
306
+
307
+ def _extract_usage(resp: Any) -> Optional[Dict[str, Any]]:
308
+ try:
309
+ u = getattr(resp, "usage", None)
310
+ if u is None:
311
+ return None
312
+ prompt = getattr(u, "prompt_tokens", None)
313
+ completion = getattr(u, "completion_tokens", None)
314
+ total = getattr(u, "total_tokens", None)
315
+ if prompt is None and completion is None and total is None:
316
+ return None
317
+ return {
318
+ "input": int(prompt or 0),
319
+ "output": int(completion or 0),
320
+ "total": int(total or ((prompt or 0) + (completion or 0))),
321
+ "unit": "TOKENS",
322
+ }
323
+ except Exception:
324
+ return None
325
+
326
+
327
+ def _extract_stream_delta_text(chunk: Any) -> Optional[str]:
328
+ try:
329
+ choices = getattr(chunk, "choices", None)
330
+ if not choices:
331
+ return None
332
+ delta = getattr(choices[0], "delta", None)
333
+ if delta is None:
334
+ return None
335
+ return getattr(delta, "content", None)
336
+ except Exception:
337
+ return None
338
+
339
+
340
+ def _safe_get_int(d: Optional[Dict[str, Any]], path: list[str]) -> Optional[int]:
341
+ if not d:
342
+ return None
343
+ cur: Any = d
344
+ for p in path:
345
+ if not isinstance(cur, dict) or p not in cur:
346
+ return None
347
+ cur = cur[p]
348
+ try:
349
+ return int(cur)
350
+ except Exception:
351
+ return None
352
+
353
+
354
+ def _safe_get_str(d: Optional[Dict[str, Any]], path: list[str]) -> Optional[str]:
355
+ if not d:
356
+ return None
357
+ cur: Any = d
358
+ for p in path:
359
+ if not isinstance(cur, dict) or p not in cur:
360
+ return None
361
+ cur = cur[p]
362
+ if cur is None:
363
+ return None
364
+ return str(cur)
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: xtrace-sdk
3
+ Version: 0.0.3
4
+ Summary: XTrace Python SDK
5
+ Requires-Python: >=3.9
6
+ Requires-Dist: requests>=2.31
@@ -0,0 +1,9 @@
1
+ pyproject.toml
2
+ xtrace_sdk/__init__.py
3
+ xtrace_sdk/client.py
4
+ xtrace_sdk/openai.py
5
+ xtrace_sdk.egg-info/PKG-INFO
6
+ xtrace_sdk.egg-info/SOURCES.txt
7
+ xtrace_sdk.egg-info/dependency_links.txt
8
+ xtrace_sdk.egg-info/requires.txt
9
+ xtrace_sdk.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ requests>=2.31
@@ -0,0 +1 @@
1
+ xtrace_sdk