usegradient 1.3.1__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.
@@ -0,0 +1,132 @@
1
+ from __future__ import annotations
2
+
3
+ import atexit
4
+ from dataclasses import dataclass
5
+ from typing import Any, Mapping
6
+
7
+ from .client import GradientClient
8
+ from .models import CreateTraceRunResponse, MachineCreateRequest
9
+ from .tracing import SpanExporter, Tracer, set_tracer
10
+
11
+ _current_session: "GradientSession | None" = None
12
+
13
+
14
+ @dataclass
15
+ class GradientSession:
16
+ project: str
17
+ client: GradientClient
18
+ run: CreateTraceRunResponse
19
+ tracer: Tracer
20
+ machine: dict[str, Any] | None = None
21
+ full_proxy_url: str | None = None
22
+ session_id: str | None = None
23
+ user_id: str | None = None
24
+ _shutdown: bool = False
25
+
26
+ def flush(self) -> None:
27
+ self.tracer.flush()
28
+
29
+ def shutdown(self) -> None:
30
+ if self._shutdown:
31
+ return
32
+ self._shutdown = True
33
+ self.flush()
34
+ self.tracer.shutdown()
35
+ try:
36
+ self.client.finish_trace_run(self.run.id)
37
+ except Exception:
38
+ pass
39
+ global _current_session
40
+ if _current_session is self:
41
+ _current_session = None
42
+ set_tracer(None)
43
+
44
+ def __enter__(self) -> "GradientSession":
45
+ return self
46
+
47
+ def __exit__(self, exc_type, exc, tb) -> bool:
48
+ self.shutdown()
49
+ return False
50
+
51
+
52
+ def current_session() -> GradientSession | None:
53
+ return _current_session
54
+
55
+
56
+ def init(
57
+ project: str = "default",
58
+ *,
59
+ client: GradientClient | None = None,
60
+ environment: MachineCreateRequest | Mapping[str, Any] | None = None,
61
+ trace_mode: str = "full",
62
+ session_id: str | None = None,
63
+ user_id: str | None = None,
64
+ metadata: Mapping[str, Any] | None = None,
65
+ capture_policy: Mapping[str, Any] | None = None,
66
+ wait_for_environment: bool = False,
67
+ wait_timeout_seconds: int = 30,
68
+ ) -> GradientSession:
69
+ """Initialize a Gradient project session with automatic tracing."""
70
+
71
+ global _current_session
72
+ if _current_session is not None:
73
+ _current_session.shutdown()
74
+
75
+ resolved_client = client or GradientClient.from_credentials()
76
+ run_metadata = {"project": project, **dict(metadata or {})}
77
+ machine: dict[str, Any] | None = None
78
+ full_proxy_url: str | None = None
79
+
80
+ if environment is not None:
81
+ traced = resolved_client.create_traced_machine(
82
+ environment,
83
+ trace_mode=trace_mode,
84
+ metadata=run_metadata,
85
+ wait=wait_for_environment,
86
+ wait_timeout_seconds=wait_timeout_seconds,
87
+ )
88
+ run = CreateTraceRunResponse.from_dict(traced["trace_run"])
89
+ run.ingest_token = traced["ingest_token"]
90
+ run.ingest_url = traced["ingest_url"]
91
+ machine = traced["machine"]
92
+ full_proxy_url = traced["full_proxy_url"]
93
+ else:
94
+ run = resolved_client.create_trace_run(
95
+ trace_mode=trace_mode,
96
+ metadata=run_metadata,
97
+ capture_policy=capture_policy,
98
+ )
99
+
100
+ ingest_endpoint = resolved_client.api_url.rstrip("/") + "/v1/traces"
101
+ exporter = SpanExporter(
102
+ endpoint=ingest_endpoint,
103
+ ingest_token=run.ingest_token,
104
+ resource={
105
+ "service.name": project,
106
+ "openinference.project.name": project,
107
+ "gradient.run.id": run.id,
108
+ },
109
+ transport=resolved_client._transport,
110
+ timeout=resolved_client.timeout,
111
+ )
112
+ tracer = Tracer(
113
+ project=project,
114
+ session_id=session_id,
115
+ user_id=user_id,
116
+ exporter=exporter,
117
+ )
118
+ set_tracer(tracer)
119
+
120
+ session = GradientSession(
121
+ project=project,
122
+ client=resolved_client,
123
+ run=run,
124
+ tracer=tracer,
125
+ machine=machine,
126
+ full_proxy_url=full_proxy_url,
127
+ session_id=session_id,
128
+ user_id=user_id,
129
+ )
130
+ _current_session = session
131
+ atexit.register(session.shutdown)
132
+ return session
gradient_sdk/tools.py ADDED
@@ -0,0 +1,156 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ import json
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Callable, get_args, get_origin, get_type_hints
7
+
8
+ from .context import current_context
9
+ from .tracing import get_tracer
10
+
11
+
12
+ @dataclass
13
+ class Tool:
14
+ name: str
15
+ description: str
16
+ fn: Callable[..., Any]
17
+ parameters: dict[str, Any] = field(default_factory=dict)
18
+
19
+ def invoke(self, **kwargs: Any) -> Any:
20
+ tracer = get_tracer()
21
+ with tracer.start_span(f"tool.{self.name}", kind="TOOL") as span:
22
+ span.set_attribute("tool.name", self.name)
23
+ span.set_attribute("input.value", json.dumps(kwargs, default=str))
24
+ context = current_context()
25
+ if context is not None:
26
+ span.set_attribute("gradient.run.id", context.run_id)
27
+ span.set_attribute("gradient.project", context.project)
28
+ if context.session_id:
29
+ span.set_attribute("session.id", context.session_id)
30
+ if context.user_id:
31
+ span.set_attribute("user.id", context.user_id)
32
+ try:
33
+ result = self.fn(**kwargs)
34
+ span.set_output(result)
35
+ return result
36
+ except Exception as exc:
37
+ span.record_exception(exc)
38
+ raise
39
+
40
+ def to_openai(self) -> dict[str, Any]:
41
+ return {
42
+ "type": "function",
43
+ "function": {
44
+ "name": self.name,
45
+ "description": self.description,
46
+ "parameters": self.parameters,
47
+ },
48
+ }
49
+
50
+ def to_anthropic(self) -> dict[str, Any]:
51
+ return {
52
+ "name": self.name,
53
+ "description": self.description,
54
+ "input_schema": self.parameters,
55
+ }
56
+
57
+
58
+ def _json_type(annotation: Any) -> str:
59
+ origin = get_origin(annotation)
60
+ if origin is list:
61
+ return "array"
62
+ if origin is dict:
63
+ return "object"
64
+ if origin is tuple:
65
+ return "array"
66
+ if annotation in (str, inspect.Parameter.empty):
67
+ return "string"
68
+ if annotation is int:
69
+ return "integer"
70
+ if annotation is float:
71
+ return "number"
72
+ if annotation is bool:
73
+ return "boolean"
74
+ if origin is type(None) or annotation is type(None):
75
+ return "null"
76
+ args = get_args(annotation)
77
+ if args:
78
+ non_none = [arg for arg in args if arg is not type(None)]
79
+ if non_none:
80
+ return _json_type(non_none[0])
81
+ return "string"
82
+
83
+
84
+ def infer_parameters(fn: Callable[..., Any]) -> dict[str, Any]:
85
+ signature = inspect.signature(fn)
86
+ try:
87
+ hints = get_type_hints(fn)
88
+ except Exception:
89
+ hints = {}
90
+ properties: dict[str, Any] = {}
91
+ required: list[str] = []
92
+ for name, param in signature.parameters.items():
93
+ if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
94
+ continue
95
+ annotation = hints.get(name, param.annotation)
96
+ properties[name] = {"type": _json_type(annotation)}
97
+ if param.default is inspect.Parameter.empty:
98
+ required.append(name)
99
+ schema: dict[str, Any] = {"type": "object", "properties": properties}
100
+ if required:
101
+ schema["required"] = required
102
+ return schema
103
+
104
+
105
+ def tool_from_function(
106
+ fn: Callable[..., Any],
107
+ *,
108
+ name: str | None = None,
109
+ description: str | None = None,
110
+ parameters: dict[str, Any] | None = None,
111
+ ) -> Tool:
112
+ resolved_name = name or getattr(fn, "__name__", "tool")
113
+ resolved_description = description or inspect.getdoc(fn) or f"Tool {resolved_name}"
114
+ return Tool(
115
+ name=resolved_name,
116
+ description=resolved_description,
117
+ fn=fn,
118
+ parameters=parameters or infer_parameters(fn),
119
+ )
120
+
121
+
122
+ def execute_tool_calls(tools: list[Tool], calls: list[dict[str, Any]]) -> list[dict[str, Any]]:
123
+ by_name = {tool.name: tool for tool in tools}
124
+ results: list[dict[str, Any]] = []
125
+ for call in calls:
126
+ name = call.get("name") or call.get("function", {}).get("name")
127
+ if not name:
128
+ continue
129
+ tool = by_name.get(name)
130
+ if tool is None:
131
+ results.append({"tool_call_id": call.get("id"), "name": name, "error": f"unknown tool: {name}"})
132
+ continue
133
+ raw_args = call.get("arguments") or call.get("function", {}).get("arguments") or call.get("input") or {}
134
+ if isinstance(raw_args, str):
135
+ try:
136
+ raw_args = json.loads(raw_args)
137
+ except json.JSONDecodeError:
138
+ raw_args = {}
139
+ try:
140
+ output = tool.invoke(**raw_args)
141
+ results.append(
142
+ {
143
+ "tool_call_id": call.get("id"),
144
+ "name": name,
145
+ "output": output,
146
+ }
147
+ )
148
+ except Exception as exc:
149
+ results.append(
150
+ {
151
+ "tool_call_id": call.get("id"),
152
+ "name": name,
153
+ "error": str(exc),
154
+ }
155
+ )
156
+ return results
@@ -0,0 +1,361 @@
1
+ from __future__ import annotations
2
+
3
+ import atexit
4
+ import json
5
+ import os
6
+ import secrets
7
+ import threading
8
+ import time
9
+ from contextvars import ContextVar
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Callable
12
+ from urllib.request import Request
13
+
14
+ Transport = Callable[[Request, float], Any]
15
+
16
+ _current_span: ContextVar["Span | None"] = ContextVar("gradient_current_span", default=None)
17
+ _global_tracer: "Tracer | None" = None
18
+
19
+
20
+ def _now_ns() -> int:
21
+ return time.time_ns()
22
+
23
+
24
+ def _otlp_value(value: Any) -> dict[str, Any]:
25
+ if isinstance(value, bool):
26
+ return {"boolValue": value}
27
+ if isinstance(value, int):
28
+ return {"intValue": str(value)}
29
+ if isinstance(value, float):
30
+ return {"doubleValue": value}
31
+ if isinstance(value, (list, tuple)):
32
+ return {"arrayValue": {"values": [_otlp_value(item) for item in value]}}
33
+ return {"stringValue": str(value)}
34
+
35
+
36
+ def _otlp_attributes(attributes: dict[str, Any]) -> list[dict[str, Any]]:
37
+ return [{"key": key, "value": _otlp_value(value)} for key, value in attributes.items()]
38
+
39
+
40
+ def _serialize_span(span: "Span", resource: dict[str, Any], scope: dict[str, str]) -> dict[str, Any]:
41
+ return {
42
+ "traceId": span.trace_id,
43
+ "spanId": span.span_id,
44
+ "parentSpanId": span.parent_span_id or "",
45
+ "name": span.name,
46
+ "kind": "SPAN_KIND_INTERNAL",
47
+ "startTimeUnixNano": str(span.start_time_ns),
48
+ "endTimeUnixNano": str(span.end_time_ns or _now_ns()),
49
+ "attributes": _otlp_attributes(span.attributes),
50
+ "events": [
51
+ {
52
+ "timeUnixNano": str(event.get("time_unix_nano", _now_ns())),
53
+ "name": event.get("name", "exception"),
54
+ "attributes": _otlp_attributes(event.get("attributes", {})),
55
+ }
56
+ for event in span.events
57
+ ],
58
+ "status": {
59
+ "code": "STATUS_CODE_ERROR" if span.status_code == "ERROR" else "STATUS_CODE_OK",
60
+ "message": span.status_message or "",
61
+ },
62
+ }
63
+
64
+
65
+ @dataclass
66
+ class Span:
67
+ name: str
68
+ trace_id: str
69
+ span_id: str
70
+ parent_span_id: str | None = None
71
+ kind: str = "CHAIN"
72
+ start_time_ns: int = field(default_factory=_now_ns)
73
+ end_time_ns: int | None = None
74
+ attributes: dict[str, Any] = field(default_factory=dict)
75
+ events: list[dict[str, Any]] = field(default_factory=list)
76
+ status_code: str = "OK"
77
+ status_message: str = ""
78
+ _ended: bool = False
79
+ _token: Any = None
80
+ _tracer: "Tracer | None" = None
81
+
82
+ def set_attribute(self, key: str, value: Any) -> None:
83
+ self.attributes[key] = value
84
+
85
+ def set_attributes(self, values: dict[str, Any]) -> None:
86
+ self.attributes.update(values)
87
+
88
+ def set_output(self, value: Any, *, mime_type: str = "application/json") -> None:
89
+ if isinstance(value, str):
90
+ self.set_attribute("output.value", value)
91
+ self.set_attribute("output.mime_type", "text/plain")
92
+ return
93
+ self.set_attribute("output.value", json.dumps(value, default=str))
94
+ self.set_attribute("output.mime_type", mime_type)
95
+
96
+ def record_exception(self, exc: BaseException) -> None:
97
+ self.status_code = "ERROR"
98
+ self.status_message = str(exc)
99
+ self.events.append(
100
+ {
101
+ "name": "exception",
102
+ "time_unix_nano": _now_ns(),
103
+ "attributes": {
104
+ "exception.type": type(exc).__name__,
105
+ "exception.message": str(exc),
106
+ },
107
+ }
108
+ )
109
+
110
+ def end(self) -> None:
111
+ if self._ended:
112
+ return
113
+ self.end_time_ns = _now_ns()
114
+ self._ended = True
115
+ if self._token is not None:
116
+ _current_span.reset(self._token)
117
+ self._token = None
118
+ if self._tracer is not None:
119
+ self._tracer._on_span_end(self)
120
+
121
+ def __enter__(self) -> "Span":
122
+ return self
123
+
124
+ def __exit__(self, exc_type, exc, tb) -> bool:
125
+ if exc is not None and exc_type is not None:
126
+ self.record_exception(exc) # type: ignore[arg-type]
127
+ self.end()
128
+ return False
129
+
130
+
131
+ class NoOpSpan:
132
+ def set_attribute(self, key: str, value: Any) -> None:
133
+ return None
134
+
135
+ def set_attributes(self, values: dict[str, Any]) -> None:
136
+ return None
137
+
138
+ def set_output(self, value: Any, *, mime_type: str = "application/json") -> None:
139
+ return None
140
+
141
+ def record_exception(self, exc: BaseException) -> None:
142
+ return None
143
+
144
+ def end(self) -> None:
145
+ return None
146
+
147
+ def __enter__(self) -> "NoOpSpan":
148
+ return self
149
+
150
+ def __exit__(self, exc_type, exc, tb) -> bool:
151
+ return False
152
+
153
+
154
+ class SpanExporter:
155
+ def __init__(
156
+ self,
157
+ *,
158
+ endpoint: str,
159
+ ingest_token: str,
160
+ resource: dict[str, Any],
161
+ scope_name: str = "gradient-sdk",
162
+ scope_version: str = "0.3.0",
163
+ transport: Transport | None = None,
164
+ timeout: float = 10.0,
165
+ batch_size: int = 32,
166
+ flush_interval_seconds: float = 1.0,
167
+ ) -> None:
168
+ self.endpoint = endpoint.rstrip("/")
169
+ self.ingest_token = ingest_token
170
+ self.resource = resource
171
+ self.scope = {"name": scope_name, "version": scope_version}
172
+ self.transport = transport
173
+ self.timeout = timeout
174
+ self.batch_size = batch_size
175
+ self.flush_interval_seconds = flush_interval_seconds
176
+ self._queue: list[Span] = []
177
+ self._lock = threading.Lock()
178
+ self._stop = threading.Event()
179
+ self._thread = threading.Thread(target=self._worker, daemon=True)
180
+ self._thread.start()
181
+ atexit.register(self.shutdown)
182
+
183
+ def enqueue(self, span: Span) -> None:
184
+ with self._lock:
185
+ self._queue.append(span)
186
+ if len(self._queue) >= self.batch_size:
187
+ self._flush_locked()
188
+
189
+ def flush(self) -> None:
190
+ with self._lock:
191
+ self._flush_locked()
192
+
193
+ def shutdown(self) -> None:
194
+ self._stop.set()
195
+ self.flush()
196
+ if self._thread.is_alive():
197
+ self._thread.join(timeout=2.0)
198
+
199
+ def _worker(self) -> None:
200
+ while not self._stop.wait(self.flush_interval_seconds):
201
+ self.flush()
202
+
203
+ def _flush_locked(self) -> None:
204
+ if not self._queue:
205
+ return
206
+ spans = self._queue[:]
207
+ self._queue.clear()
208
+ payload = {
209
+ "resourceSpans": [
210
+ {
211
+ "resource": {"attributes": _otlp_attributes(self.resource)},
212
+ "scopeSpans": [
213
+ {
214
+ "scope": self.scope,
215
+ "spans": [_serialize_span(span, self.resource, self.scope) for span in spans],
216
+ }
217
+ ],
218
+ }
219
+ ]
220
+ }
221
+ req = Request(
222
+ self.endpoint,
223
+ data=json.dumps(payload).encode("utf-8"),
224
+ method="POST",
225
+ headers={
226
+ "Authorization": f"Bearer {self.ingest_token}",
227
+ "Content-Type": "application/json",
228
+ "Accept": "application/json",
229
+ },
230
+ )
231
+ try:
232
+ if self.transport is not None:
233
+ with self.transport(req, self.timeout) as res:
234
+ _ = res.read()
235
+ else:
236
+ from urllib.request import urlopen
237
+
238
+ with urlopen(req, timeout=self.timeout) as res:
239
+ _ = res.read()
240
+ except Exception:
241
+ return
242
+
243
+
244
+ class Tracer:
245
+ def __init__(
246
+ self,
247
+ *,
248
+ project: str,
249
+ session_id: str | None = None,
250
+ user_id: str | None = None,
251
+ exporter: SpanExporter | None = None,
252
+ ) -> None:
253
+ self.project = project
254
+ self.session_id = session_id
255
+ self.user_id = user_id
256
+ self.exporter = exporter
257
+ self.resource = {
258
+ "service.name": project,
259
+ "openinference.project.name": project,
260
+ }
261
+
262
+ def start_span(
263
+ self,
264
+ name: str,
265
+ *,
266
+ kind: str = "CHAIN",
267
+ attributes: dict[str, Any] | None = None,
268
+ ) -> Span | NoOpSpan:
269
+ if self.exporter is None:
270
+ return NoOpSpan()
271
+
272
+ parent = _current_span.get()
273
+ trace_id = parent.trace_id if parent is not None else secrets.token_hex(16)
274
+ parent_span_id = parent.span_id if parent is not None else None
275
+ span = Span(
276
+ name=name,
277
+ trace_id=trace_id,
278
+ span_id=secrets.token_hex(8),
279
+ parent_span_id=parent_span_id,
280
+ kind=kind,
281
+ _tracer=self,
282
+ )
283
+ span.set_attribute("openinference.span.kind", kind)
284
+ if self.session_id:
285
+ span.set_attribute("session.id", self.session_id)
286
+ if self.user_id:
287
+ span.set_attribute("user.id", self.user_id)
288
+ if attributes:
289
+ span.set_attributes(attributes)
290
+ span._token = _current_span.set(span)
291
+ return span
292
+
293
+ def _on_span_end(self, span: Span) -> None:
294
+ if self.exporter is not None:
295
+ self.exporter.enqueue(span)
296
+
297
+ def flush(self) -> None:
298
+ if self.exporter is not None:
299
+ self.exporter.flush()
300
+
301
+ def shutdown(self) -> None:
302
+ if self.exporter is not None:
303
+ self.exporter.shutdown()
304
+
305
+
306
+ class NoOpTracer:
307
+ def start_span(
308
+ self,
309
+ name: str,
310
+ *,
311
+ kind: str = "CHAIN",
312
+ attributes: dict[str, Any] | None = None,
313
+ ) -> NoOpSpan:
314
+ return NoOpSpan()
315
+
316
+ def flush(self) -> None:
317
+ return None
318
+
319
+ def shutdown(self) -> None:
320
+ return None
321
+
322
+
323
+ _NO_OP_TRACER = NoOpTracer()
324
+
325
+
326
+ def get_tracer() -> Tracer | NoOpTracer:
327
+ global _global_tracer
328
+ if _global_tracer is None:
329
+ token = os.environ.get("GRADIENT_TRACE_TOKEN", "")
330
+ endpoint = os.environ.get("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "")
331
+ if token and endpoint:
332
+ project = (
333
+ os.environ.get("GRADIENT_PROJECT")
334
+ or os.environ.get("OTEL_SERVICE_NAME")
335
+ or "gradient"
336
+ )
337
+ run_id = os.environ.get("GRADIENT_TRACE_RUN_ID", "")
338
+ resource = {
339
+ "service.name": project,
340
+ "openinference.project.name": project,
341
+ }
342
+ if run_id:
343
+ resource["gradient.run.id"] = run_id
344
+ _global_tracer = Tracer(
345
+ project=project,
346
+ exporter=SpanExporter(
347
+ endpoint=endpoint,
348
+ ingest_token=token,
349
+ resource=resource,
350
+ ),
351
+ )
352
+ return _global_tracer or _NO_OP_TRACER
353
+
354
+
355
+ def set_tracer(tracer: Tracer | None) -> None:
356
+ global _global_tracer
357
+ _global_tracer = tracer
358
+
359
+
360
+ def current_span() -> Span | NoOpSpan | None:
361
+ return _current_span.get()