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.
gradient_sdk/client.py ADDED
@@ -0,0 +1,1059 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, is_dataclass
4
+ from typing import Any, Callable, Mapping
5
+ from urllib.error import HTTPError, URLError
6
+ from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
7
+ from urllib.parse import quote
8
+ from urllib.request import Request, urlopen
9
+ import json
10
+ import re
11
+ import time
12
+
13
+ from .credentials import Credentials, load_credentials
14
+ from .errors import GradientAPIError, GradientAuthError, GradientError
15
+ from .models import (
16
+ AppSecret,
17
+ CreateMachineResponse,
18
+ ExecMachineResponse,
19
+ Guest,
20
+ MachineCreateRequest,
21
+ MachineListItem,
22
+ MachineSpec,
23
+ Restart,
24
+ ServiceConfig,
25
+ CreateTraceRunResponse,
26
+ ReplayTraceRunResponse,
27
+ TraceEvent,
28
+ TraceRun,
29
+ TraceSpan,
30
+ WhoamiResponse,
31
+ to_payload,
32
+ )
33
+
34
+ Transport = Callable[[Request, float], Any]
35
+
36
+
37
+ class GradientClient:
38
+ """Synchronous client for the Gradient console API."""
39
+
40
+ def __init__(
41
+ self,
42
+ api_key: str,
43
+ *,
44
+ api_url: str = "https://console.usegradient.dev",
45
+ proxy_url: str = "",
46
+ slug: str = "",
47
+ timeout: float = 90.0,
48
+ transport: Transport | None = None,
49
+ ) -> None:
50
+ if not api_key:
51
+ raise GradientAuthError(
52
+ "missing API key. Set GRADIENT_API_KEY or run `gradient login --api-key grad_live_...`."
53
+ )
54
+ self.api_key = api_key
55
+ self.api_url = api_url.rstrip("/")
56
+ self.proxy_url_base = proxy_url.rstrip("/")
57
+ self.slug = slug
58
+ self.timeout = timeout
59
+ self.last_server_timing = ""
60
+ self._transport = transport
61
+
62
+ @classmethod
63
+ def from_credentials(
64
+ cls,
65
+ *,
66
+ path: str | None = None,
67
+ timeout: float = 90.0,
68
+ transport: Transport | None = None,
69
+ ) -> "GradientClient":
70
+ credentials = load_credentials(path=path, require_api_key=True)
71
+ return cls.from_loaded_credentials(credentials, timeout=timeout, transport=transport)
72
+
73
+ @classmethod
74
+ def from_loaded_credentials(
75
+ cls,
76
+ credentials: Credentials,
77
+ *,
78
+ timeout: float = 90.0,
79
+ transport: Transport | None = None,
80
+ ) -> "GradientClient":
81
+ return cls(
82
+ credentials.api_key,
83
+ api_url=credentials.api_url,
84
+ proxy_url=credentials.proxy_url,
85
+ slug=credentials.slug,
86
+ timeout=timeout,
87
+ transport=transport,
88
+ )
89
+
90
+ def whoami(self) -> WhoamiResponse:
91
+ data = self._call("GET", "/api/v1/whoami")
92
+ who = WhoamiResponse.from_dict(data)
93
+ if who.proxy_url:
94
+ self.proxy_url_base = who.proxy_url.rstrip("/")
95
+ if who.slug:
96
+ self.slug = who.slug
97
+ return who
98
+
99
+ def ensure_routable(self) -> None:
100
+ self._call("POST", "/api/v1/apps/ensure-routable", {})
101
+
102
+ def list_projects(self) -> list[dict[str, Any]]:
103
+ return list(self._call("GET", "/api/v1/projects"))
104
+
105
+ def ensure_project(self, name: str, *, display_name: str | None = None) -> dict[str, Any]:
106
+ body: dict[str, Any] = {"name": name}
107
+ if display_name is not None:
108
+ body["display_name"] = display_name
109
+ return dict(self._call("POST", "/api/v1/projects", body))
110
+
111
+ def resolve_environment(
112
+ self,
113
+ *,
114
+ project: str,
115
+ environment: Mapping[str, Any] | None = None,
116
+ source: Mapping[str, Any] | None = None,
117
+ display_name: str | None = None,
118
+ ) -> dict[str, Any]:
119
+ body: dict[str, Any] = {
120
+ "project": project,
121
+ "environment": dict(environment or {}),
122
+ "source": dict(source or {}),
123
+ }
124
+ if display_name is not None:
125
+ body["display_name"] = display_name
126
+ return dict(self._call("POST", "/api/v1/environments", body))
127
+
128
+ def list_environment_versions(self, *, project: str | None = None) -> list[dict[str, Any]]:
129
+ suffix = f"?project={quote(project, safe='')}" if project else ""
130
+ return list(self._call("GET", f"/api/v1/environments{suffix}"))
131
+
132
+ def list_deployments(self) -> list[dict[str, Any]]:
133
+ return list(self._call("GET", "/api/v1/deployments"))
134
+
135
+ def create_deployment(
136
+ self,
137
+ *,
138
+ name: str,
139
+ project: str,
140
+ template: Mapping[str, Any],
141
+ environment_version_id: str | None = None,
142
+ trace_mode: str = "full",
143
+ capture_policy: Mapping[str, Any] | None = None,
144
+ metadata: Mapping[str, Any] | None = None,
145
+ min_machines: int = 1,
146
+ max_machines: int = 1,
147
+ ) -> dict[str, Any]:
148
+ body: dict[str, Any] = {
149
+ "name": name,
150
+ "project": project,
151
+ "environment_version_id": environment_version_id,
152
+ "template": dict(template),
153
+ "trace_mode": trace_mode,
154
+ "metadata": dict(metadata or {}),
155
+ "scaling": {
156
+ "min_machines": min_machines,
157
+ "max_machines": max_machines,
158
+ },
159
+ }
160
+ if capture_policy is not None:
161
+ body["capture_policy"] = dict(capture_policy)
162
+ return dict(
163
+ self._call(
164
+ "POST",
165
+ "/api/v1/deployments",
166
+ body,
167
+ )
168
+ )
169
+
170
+ def get_deployment(self, name: str) -> dict[str, Any]:
171
+ return dict(self._call("GET", f"/api/v1/deployments/{quote(name, safe='')}"))
172
+
173
+ def start_deployment(self, name: str) -> dict[str, Any]:
174
+ return dict(
175
+ self._call("PATCH", f"/api/v1/deployments/{quote(name, safe='')}", {"action": "start"})
176
+ )
177
+
178
+ def stop_deployment(self, name: str) -> dict[str, Any]:
179
+ return dict(
180
+ self._call("PATCH", f"/api/v1/deployments/{quote(name, safe='')}", {"action": "stop"})
181
+ )
182
+
183
+ def scale_deployment(self, name: str, machines: int) -> dict[str, Any]:
184
+ return dict(
185
+ self._call(
186
+ "PATCH",
187
+ f"/api/v1/deployments/{quote(name, safe='')}",
188
+ {"action": "scale", "machines": machines},
189
+ )
190
+ )
191
+
192
+ def delete_deployment(self, name: str) -> dict[str, Any]:
193
+ return dict(self._call("DELETE", f"/api/v1/deployments/{quote(name, safe='')}"))
194
+
195
+ def create_machine(self, template: MachineCreateRequest | Mapping[str, Any]) -> CreateMachineResponse:
196
+ data = self._call("POST", "/api/v1/machines", _payload(template))
197
+ return CreateMachineResponse.from_dict(data)
198
+
199
+ def create_traced_machine(
200
+ self,
201
+ template: MachineCreateRequest | Mapping[str, Any],
202
+ *,
203
+ machine_template: MachineCreateRequest | Mapping[str, Any] | None = None,
204
+ trace_mode: str = "full",
205
+ capture_policy: Mapping[str, Any] | None = None,
206
+ metadata: Mapping[str, Any] | None = None,
207
+ wait: bool = False,
208
+ wait_timeout_seconds: int = 30,
209
+ ) -> dict[str, Any]:
210
+ """Create a trace run, inject trace env vars, cold-create a machine, and attach the run."""
211
+
212
+ trace = self.create_trace_run(
213
+ trace_mode=trace_mode,
214
+ template=template,
215
+ metadata=metadata,
216
+ capture_policy=capture_policy,
217
+ )
218
+ traced_template = self.trace_template(machine_template or template, trace)
219
+ machine = self.create_machine(traced_template)
220
+ if wait:
221
+ self.wait_machine_state(machine.id, timeout_seconds=wait_timeout_seconds)
222
+ full_proxy_url = self.proxy_url(
223
+ machine.id,
224
+ slug=machine.slug,
225
+ proxy_base=machine.proxy_url,
226
+ proxy_session_token=machine.proxy_session_token,
227
+ )
228
+ attached = self.attach_trace_run(
229
+ trace.id,
230
+ machine_id=machine.id,
231
+ proxy_url=full_proxy_url,
232
+ status="active",
233
+ )
234
+ return {
235
+ "machine": asdict(machine),
236
+ "trace_run": asdict(attached),
237
+ "ingest_token": trace.ingest_token,
238
+ "ingest_url": trace.ingest_url,
239
+ "full_proxy_url": full_proxy_url,
240
+ }
241
+
242
+ def replay_machine(
243
+ self,
244
+ parent_run_id: str,
245
+ *,
246
+ trace_mode: str | None = None,
247
+ replay_config: Mapping[str, Any] | None = None,
248
+ capture_policy: Mapping[str, Any] | None = None,
249
+ metadata: Mapping[str, Any] | None = None,
250
+ wait: bool = False,
251
+ wait_timeout_seconds: int = 30,
252
+ ) -> dict[str, Any]:
253
+ """Fork a prior trace run into a fresh sandbox and re-run it.
254
+
255
+ Re-creates the parent run's machine template, injects trace and
256
+ GRADIENT_REPLAY_* env vars (seed, frozen clock, dataset, egress mode),
257
+ cold-creates a machine, and attaches the linked child run.
258
+ """
259
+
260
+ replay = self.replay_trace_run(
261
+ parent_run_id,
262
+ trace_mode=trace_mode,
263
+ replay_config=replay_config,
264
+ capture_policy=capture_policy,
265
+ metadata=metadata,
266
+ )
267
+ if not replay.template:
268
+ raise GradientError(
269
+ "parent run has no machine template to replay; only runs created "
270
+ "with `gradient up --trace` can be forked."
271
+ )
272
+ template = self.trace_template(replay.template, replay)
273
+ template = _with_replay_env(template, replay.replay_config)
274
+ machine = self.create_machine(template)
275
+ if wait:
276
+ self.wait_machine_state(machine.id, timeout_seconds=wait_timeout_seconds)
277
+ full_proxy_url = self.proxy_url(
278
+ machine.id,
279
+ slug=machine.slug,
280
+ proxy_base=machine.proxy_url,
281
+ proxy_session_token=machine.proxy_session_token,
282
+ )
283
+ attached = self.attach_trace_run(
284
+ replay.id,
285
+ machine_id=machine.id,
286
+ proxy_url=full_proxy_url,
287
+ status="active",
288
+ )
289
+ return {
290
+ "machine": asdict(machine),
291
+ "trace_run": asdict(attached),
292
+ "parent_run_id": parent_run_id,
293
+ "replay_config": replay.replay_config,
294
+ "ingest_token": replay.ingest_token,
295
+ "ingest_url": replay.ingest_url,
296
+ "full_proxy_url": full_proxy_url,
297
+ }
298
+
299
+ def list_machines(self) -> list[MachineListItem]:
300
+ data = self._call("GET", "/api/v1/machines")
301
+ return [MachineListItem.from_dict(item) for item in data]
302
+
303
+ def delete_machine(self, machine_id: str) -> None:
304
+ if not machine_id:
305
+ raise ValueError("machine_id is required")
306
+ self._call("DELETE", f"/api/v1/machines/{machine_id}")
307
+
308
+ def exec_machine(
309
+ self,
310
+ machine_id: str,
311
+ command: list[str],
312
+ *,
313
+ stdin: str | None = None,
314
+ timeout_seconds: int = 60,
315
+ trace: bool = True,
316
+ watch: list[str] | None = None,
317
+ ) -> ExecMachineResponse:
318
+ if not machine_id:
319
+ raise ValueError("machine_id is required")
320
+ if not command:
321
+ raise ValueError("command is required")
322
+ body = {
323
+ "command": command,
324
+ "timeout": timeout_seconds,
325
+ "trace": trace,
326
+ "watch": watch or [],
327
+ }
328
+ if stdin is not None:
329
+ body["stdin"] = stdin
330
+ data = self._call("POST", f"/api/v1/machines/{machine_id}/exec", body)
331
+ return ExecMachineResponse.from_dict(data)
332
+
333
+ def list_secrets(self) -> list[AppSecret]:
334
+ data = self._call("GET", "/api/v1/secrets")
335
+ return [AppSecret.from_dict(item) for item in data]
336
+
337
+ def update_secrets(self, values: Mapping[str, str | None]) -> list[AppSecret]:
338
+ if not values:
339
+ raise ValueError("values must not be empty")
340
+ data = self._call("POST", "/api/v1/secrets", {"values": dict(values)})
341
+ return [AppSecret.from_dict(item) for item in data]
342
+
343
+ def set_secret(self, name: str, value: str) -> list[AppSecret]:
344
+ return self.update_secrets({name: value})
345
+
346
+ def unset_secret(self, name: str) -> list[AppSecret]:
347
+ return self.update_secrets({name: None})
348
+
349
+ def wait_machine_state(
350
+ self,
351
+ machine_id: str,
352
+ *,
353
+ state: str = "started",
354
+ timeout_seconds: int = 30,
355
+ ) -> None:
356
+ if state not in {"started", "stopped", "destroyed", "suspended"}:
357
+ raise ValueError("state must be started, stopped, destroyed, or suspended")
358
+ deadline = time.monotonic() + timeout_seconds
359
+ last_error: GradientAPIError | None = None
360
+ while time.monotonic() < deadline:
361
+ remaining = max(1, min(60, int(deadline - time.monotonic())))
362
+ query = urlencode({"state": state, "timeout": remaining})
363
+ try:
364
+ self._call("GET", f"/api/v1/machines/{machine_id}/wait?{query}")
365
+ return
366
+ except GradientAPIError as exc:
367
+ last_error = exc
368
+ if exc.status != 408:
369
+ raise
370
+ if last_error is not None:
371
+ raise last_error
372
+ raise GradientError(f"timed out waiting for {machine_id} to reach {state}")
373
+
374
+ def create_trace_run(
375
+ self,
376
+ *,
377
+ trace_mode: str = "proxy",
378
+ template: MachineCreateRequest | Mapping[str, Any] | None = None,
379
+ metadata: Mapping[str, Any] | None = None,
380
+ capture_policy: Mapping[str, Any] | None = None,
381
+ ) -> CreateTraceRunResponse:
382
+ body: dict[str, Any] = {"trace_mode": trace_mode}
383
+ if template is not None:
384
+ body["template"] = _payload(template)
385
+ if metadata is not None:
386
+ body["metadata"] = dict(metadata)
387
+ if capture_policy is not None:
388
+ body["capture_policy"] = dict(capture_policy)
389
+ data = self._call("POST", "/api/v1/runs", body)
390
+ return CreateTraceRunResponse.from_dict(data)
391
+
392
+ def replay_trace_run(
393
+ self,
394
+ parent_run_id: str,
395
+ *,
396
+ trace_mode: str | None = None,
397
+ replay_config: Mapping[str, Any] | None = None,
398
+ capture_policy: Mapping[str, Any] | None = None,
399
+ metadata: Mapping[str, Any] | None = None,
400
+ ) -> ReplayTraceRunResponse:
401
+ if not parent_run_id:
402
+ raise ValueError("parent_run_id is required")
403
+ body: dict[str, Any] = {}
404
+ if trace_mode is not None:
405
+ body["trace_mode"] = trace_mode
406
+ if replay_config is not None:
407
+ body["replay_config"] = dict(replay_config)
408
+ if capture_policy is not None:
409
+ body["capture_policy"] = dict(capture_policy)
410
+ if metadata is not None:
411
+ body["metadata"] = dict(metadata)
412
+ data = self._call("POST", f"/api/v1/runs/{parent_run_id}/replay", body)
413
+ return ReplayTraceRunResponse.from_dict(data)
414
+
415
+ def attach_trace_run(
416
+ self,
417
+ run_id: str,
418
+ *,
419
+ machine_id: str,
420
+ proxy_url: str | None = None,
421
+ status: str = "active",
422
+ ) -> TraceRun:
423
+ body = {
424
+ "action": "attach",
425
+ "machine_id": machine_id,
426
+ "proxy_url": proxy_url,
427
+ "status": status,
428
+ }
429
+ data = self._call("PATCH", f"/api/v1/runs/{run_id}", body)
430
+ return TraceRun.from_dict(data)
431
+
432
+ def finish_trace_run(self, run_id: str) -> TraceRun:
433
+ data = self._call("PATCH", f"/api/v1/runs/{run_id}", {"action": "finish"})
434
+ return TraceRun.from_dict(data)
435
+
436
+ def update_trace_policy(
437
+ self,
438
+ run_id: str,
439
+ capture_policy: Mapping[str, Any],
440
+ ) -> TraceRun:
441
+ data = self._call(
442
+ "PATCH",
443
+ f"/api/v1/runs/{run_id}",
444
+ {"action": "policy", "capture_policy": dict(capture_policy)},
445
+ )
446
+ return TraceRun.from_dict(data)
447
+
448
+ def delete_trace_run(self, run_id: str) -> None:
449
+ self._call("DELETE", f"/api/v1/runs/{run_id}")
450
+
451
+ def get_trace_run(self, run_id: str) -> TraceRun:
452
+ data = self._call("GET", f"/api/v1/runs/{run_id}")
453
+ return TraceRun.from_dict(data)
454
+
455
+ def list_trace_runs(self, *, limit: int = 50) -> list[TraceRun]:
456
+ data = self._call("GET", f"/api/v1/runs?limit={limit}")
457
+ return [TraceRun.from_dict(item) for item in data]
458
+
459
+ def list_trace_events(self, run_id: str, *, limit: int = 1000) -> list[TraceEvent]:
460
+ data = self._call("GET", f"/api/v1/runs/{run_id}/events?limit={limit}")
461
+ return [TraceEvent.from_dict(item) for item in data]
462
+
463
+ def list_trace_spans(
464
+ self,
465
+ run_id: str,
466
+ *,
467
+ kind: str | None = None,
468
+ status: str | None = None,
469
+ project: str | None = None,
470
+ session_id: str | None = None,
471
+ query: str | None = None,
472
+ limit: int | None = None,
473
+ ) -> list[TraceSpan]:
474
+ params = {
475
+ key: value
476
+ for key, value in {
477
+ "kind": kind,
478
+ "status": status,
479
+ "project": project,
480
+ "session_id": session_id,
481
+ "q": query,
482
+ "limit": limit,
483
+ }.items()
484
+ if value is not None
485
+ }
486
+ suffix = f"?{urlencode(params)}" if params else ""
487
+ data = self._call("GET", f"/api/v1/runs/{run_id}/spans{suffix}")
488
+ return [TraceSpan.from_dict(item) for item in data]
489
+
490
+ def list_trace_sessions(
491
+ self,
492
+ *,
493
+ limit: int = 100,
494
+ query: str | None = None,
495
+ ) -> list[dict[str, Any]]:
496
+ params: dict[str, Any] = {"limit": limit}
497
+ if query:
498
+ params["q"] = query
499
+ return list(self._call("GET", f"/api/v1/sessions?{urlencode(params)}"))
500
+
501
+ def list_session_spans(self, session_id: str) -> list[TraceSpan]:
502
+ data = self._call(
503
+ "GET",
504
+ f"/api/v1/sessions/{quote(session_id, safe='')}/spans",
505
+ )
506
+ return [TraceSpan.from_dict(item) for item in data]
507
+
508
+ def trace_metrics(self, *, days: int = 30) -> dict[str, Any]:
509
+ return dict(self._call("GET", f"/api/v1/metrics?days={days}"))
510
+
511
+ def query_traces(self, sql: str, *, limit: int = 100) -> dict[str, Any]:
512
+ if not sql or not sql.strip():
513
+ raise ValueError("sql is required")
514
+ return dict(
515
+ self._call(
516
+ "POST",
517
+ "/api/v1/traces/query",
518
+ {"sql": sql, "limit": limit},
519
+ )
520
+ )
521
+
522
+ def create_trace_annotation(
523
+ self,
524
+ *,
525
+ name: str,
526
+ value: Any,
527
+ run_id: str | None = None,
528
+ span_id: str | None = None,
529
+ trace_id: str | None = None,
530
+ source: str | None = None,
531
+ annotator: str | None = None,
532
+ metadata: Mapping[str, Any] | None = None,
533
+ ) -> dict[str, Any]:
534
+ body: dict[str, Any] = {"name": name, "value": value}
535
+ for key, item in {
536
+ "run_id": run_id,
537
+ "span_id": span_id,
538
+ "trace_id": trace_id,
539
+ "source": source,
540
+ "annotator": annotator,
541
+ }.items():
542
+ if item is not None:
543
+ body[key] = item
544
+ if metadata is not None:
545
+ body["metadata"] = dict(metadata)
546
+ return dict(self._call("POST", "/api/v1/annotations", body))
547
+
548
+ def list_trace_annotations(
549
+ self,
550
+ *,
551
+ run_id: str | None = None,
552
+ span_id: str | None = None,
553
+ trace_id: str | None = None,
554
+ ) -> list[dict[str, Any]]:
555
+ params = {
556
+ key: value
557
+ for key, value in {
558
+ "run_id": run_id,
559
+ "span_id": span_id,
560
+ "trace_id": trace_id,
561
+ }.items()
562
+ if value is not None
563
+ }
564
+ suffix = f"?{urlencode(params)}" if params else ""
565
+ return list(self._call("GET", f"/api/v1/annotations{suffix}"))
566
+
567
+ def delete_trace_annotation(self, annotation_id: str) -> None:
568
+ self._call("DELETE", f"/api/v1/annotations/{annotation_id}")
569
+
570
+ def create_dataset(
571
+ self,
572
+ name: str,
573
+ *,
574
+ description: str | None = None,
575
+ metadata: Mapping[str, Any] | None = None,
576
+ ) -> dict[str, Any]:
577
+ body: dict[str, Any] = {"name": name}
578
+ if description is not None:
579
+ body["description"] = description
580
+ if metadata is not None:
581
+ body["metadata"] = dict(metadata)
582
+ return dict(self._call("POST", "/api/v1/datasets", body))
583
+
584
+ def list_datasets(self) -> list[dict[str, Any]]:
585
+ return list(self._call("GET", "/api/v1/datasets"))
586
+
587
+ def get_dataset(self, dataset_id: str) -> dict[str, Any]:
588
+ return dict(self._call("GET", f"/api/v1/datasets/{dataset_id}"))
589
+
590
+ def delete_dataset(self, dataset_id: str) -> None:
591
+ self._call("DELETE", f"/api/v1/datasets/{dataset_id}")
592
+
593
+ def add_dataset_example(
594
+ self,
595
+ dataset_id: str,
596
+ *,
597
+ input: Any | None = None,
598
+ expected_output: Any | None = None,
599
+ metadata: Mapping[str, Any] | None = None,
600
+ split: str | None = None,
601
+ source_span_id: str | None = None,
602
+ source_trace_id: str | None = None,
603
+ ) -> dict[str, Any]:
604
+ body: dict[str, Any] = {}
605
+ for key, item in {
606
+ "input": input,
607
+ "expected_output": expected_output,
608
+ "split": split,
609
+ "source_span_id": source_span_id,
610
+ "source_trace_id": source_trace_id,
611
+ }.items():
612
+ if item is not None:
613
+ body[key] = item
614
+ if metadata is not None:
615
+ body["metadata"] = dict(metadata)
616
+ return dict(
617
+ self._call(
618
+ "POST",
619
+ f"/api/v1/datasets/{dataset_id}/examples",
620
+ body,
621
+ )
622
+ )
623
+
624
+ def list_dataset_examples(self, dataset_id: str) -> list[dict[str, Any]]:
625
+ return list(
626
+ self._call("GET", f"/api/v1/datasets/{dataset_id}/examples")
627
+ )
628
+
629
+ def delete_dataset_example(self, dataset_id: str, example_id: str) -> None:
630
+ self._call(
631
+ "DELETE",
632
+ f"/api/v1/datasets/{dataset_id}/examples/{example_id}",
633
+ )
634
+
635
+ def create_experiment(
636
+ self,
637
+ *,
638
+ dataset_id: str,
639
+ name: str,
640
+ description: str | None = None,
641
+ metadata: Mapping[str, Any] | None = None,
642
+ ) -> dict[str, Any]:
643
+ body: dict[str, Any] = {"dataset_id": dataset_id, "name": name}
644
+ if description is not None:
645
+ body["description"] = description
646
+ if metadata is not None:
647
+ body["metadata"] = dict(metadata)
648
+ return dict(self._call("POST", "/api/v1/experiments", body))
649
+
650
+ def list_experiments(self) -> list[dict[str, Any]]:
651
+ return list(self._call("GET", "/api/v1/experiments"))
652
+
653
+ def get_experiment(self, experiment_id: str) -> dict[str, Any]:
654
+ return dict(self._call("GET", f"/api/v1/experiments/{experiment_id}"))
655
+
656
+ def update_experiment_status(self, experiment_id: str, status: str) -> None:
657
+ self._call(
658
+ "PATCH",
659
+ f"/api/v1/experiments/{experiment_id}",
660
+ {"status": status},
661
+ )
662
+
663
+ def create_experiment_run(
664
+ self,
665
+ experiment_id: str,
666
+ *,
667
+ name: str,
668
+ config: Mapping[str, Any] | None = None,
669
+ ) -> dict[str, Any]:
670
+ return dict(
671
+ self._call(
672
+ "POST",
673
+ f"/api/v1/experiments/{experiment_id}/runs",
674
+ {"name": name, "config": dict(config or {})},
675
+ )
676
+ )
677
+
678
+ def record_experiment_results(
679
+ self,
680
+ experiment_id: str,
681
+ run_id: str,
682
+ results: list[Mapping[str, Any]],
683
+ ) -> int:
684
+ data = self._call(
685
+ "POST",
686
+ f"/api/v1/experiments/{experiment_id}/runs/{run_id}/results",
687
+ {"results": [dict(result) for result in results]},
688
+ )
689
+ return int(data.get("inserted", 0))
690
+
691
+ def update_experiment_run_status(
692
+ self,
693
+ experiment_id: str,
694
+ run_id: str,
695
+ status: str,
696
+ ) -> dict[str, Any]:
697
+ return dict(
698
+ self._call(
699
+ "PATCH",
700
+ f"/api/v1/experiments/{experiment_id}/runs/{run_id}",
701
+ {"status": status},
702
+ )
703
+ )
704
+
705
+ def run_experiment(
706
+ self,
707
+ dataset_id: str,
708
+ *,
709
+ task: Callable[[Any, Mapping[str, Any]], Any],
710
+ evaluators: Mapping[str, Callable[[Mapping[str, Any]], Any]] | None = None,
711
+ experiment_id: str | None = None,
712
+ name: str = "Experiment",
713
+ variant: str = "candidate",
714
+ config: Mapping[str, Any] | None = None,
715
+ metadata: Mapping[str, Any] | None = None,
716
+ batch_size: int = 50,
717
+ ) -> dict[str, Any]:
718
+ dataset = self.get_dataset(dataset_id)
719
+ experiment = (
720
+ {"id": experiment_id}
721
+ if experiment_id
722
+ else self.create_experiment(
723
+ dataset_id=dataset_id,
724
+ name=name,
725
+ metadata=metadata,
726
+ )
727
+ )
728
+ run = self.create_experiment_run(
729
+ str(experiment["id"]),
730
+ name=variant,
731
+ config=config,
732
+ )
733
+ pending: list[dict[str, Any]] = []
734
+ try:
735
+ for example in dataset.get("examples", []):
736
+ started = time.perf_counter()
737
+ try:
738
+ raw = task(example.get("input"), example)
739
+ envelope = (
740
+ raw
741
+ if isinstance(raw, Mapping) and "output" in raw
742
+ else {"output": raw}
743
+ )
744
+ scores: dict[str, Any] = {}
745
+ for evaluator_name, evaluator in (evaluators or {}).items():
746
+ scores[evaluator_name] = evaluator(
747
+ {
748
+ "input": example.get("input"),
749
+ "output": envelope.get("output"),
750
+ "expected_output": example.get("expected_output"),
751
+ "metadata": example.get("metadata"),
752
+ "example": example,
753
+ }
754
+ )
755
+ result = {
756
+ "dataset_example_id": example["id"],
757
+ "output": envelope.get("output"),
758
+ "scores": scores,
759
+ "latency_ms": (time.perf_counter() - started) * 1000,
760
+ }
761
+ for wire_key, envelope_key in {
762
+ "prompt_tokens": "prompt_tokens",
763
+ "completion_tokens": "completion_tokens",
764
+ "total_tokens": "total_tokens",
765
+ "total_cost": "total_cost",
766
+ "trace_id": "trace_id",
767
+ "span_id": "span_id",
768
+ "metadata": "metadata",
769
+ }.items():
770
+ if envelope.get(envelope_key) is not None:
771
+ result[wire_key] = envelope[envelope_key]
772
+ pending.append(result)
773
+ except Exception as error:
774
+ pending.append(
775
+ {
776
+ "dataset_example_id": example["id"],
777
+ "error": str(error),
778
+ "latency_ms": (time.perf_counter() - started) * 1000,
779
+ }
780
+ )
781
+ if len(pending) >= batch_size:
782
+ self.record_experiment_results(
783
+ str(experiment["id"]),
784
+ str(run["id"]),
785
+ pending,
786
+ )
787
+ pending = []
788
+ if pending:
789
+ self.record_experiment_results(
790
+ str(experiment["id"]),
791
+ str(run["id"]),
792
+ pending,
793
+ )
794
+ self.update_experiment_run_status(
795
+ str(experiment["id"]),
796
+ str(run["id"]),
797
+ "completed",
798
+ )
799
+ self.update_experiment_status(str(experiment["id"]), "completed")
800
+ except Exception:
801
+ self.update_experiment_run_status(
802
+ str(experiment["id"]),
803
+ str(run["id"]),
804
+ "failed",
805
+ )
806
+ self.update_experiment_status(str(experiment["id"]), "failed")
807
+ raise
808
+ return self.get_experiment(str(experiment["id"]))
809
+
810
+ def ingest_trace_events(
811
+ self,
812
+ run_id: str,
813
+ ingest_token: str,
814
+ events: list[Mapping[str, Any]],
815
+ ) -> int:
816
+ data = self._call(
817
+ "POST",
818
+ "/api/v1/runs/ingest",
819
+ {"run_id": run_id, "events": [dict(event) for event in events]},
820
+ auth_token=ingest_token,
821
+ )
822
+ return int(data.get("inserted", 0))
823
+
824
+ def ingest_trace_event(
825
+ self,
826
+ run_id: str,
827
+ ingest_token: str,
828
+ *,
829
+ event_type: str,
830
+ source: str = "sdk",
831
+ data: Mapping[str, Any] | None = None,
832
+ ts: str | None = None,
833
+ machine_id: str | None = None,
834
+ ) -> int:
835
+ event: dict[str, Any] = {
836
+ "source": source,
837
+ "type": event_type,
838
+ "data": dict(data or {}),
839
+ }
840
+ if ts is not None:
841
+ event["ts"] = ts
842
+ if machine_id is not None:
843
+ event["machineId"] = machine_id
844
+ return self.ingest_trace_events(run_id, ingest_token, [event])
845
+
846
+ def trace_template(
847
+ self,
848
+ template: MachineCreateRequest | Mapping[str, Any],
849
+ trace: CreateTraceRunResponse,
850
+ ) -> dict[str, Any]:
851
+ return _with_trace_env(template, trace, self.api_url)
852
+
853
+ def proxy_url(
854
+ self,
855
+ machine_id: str,
856
+ *,
857
+ slug: str | None = None,
858
+ proxy_base: str | None = None,
859
+ proxy_session_token: str | None = None,
860
+ ) -> str:
861
+ resolved_slug = slug or self.slug
862
+ resolved_proxy = (proxy_base or self.proxy_url_base).rstrip("/")
863
+ if not resolved_proxy or not resolved_slug:
864
+ raise GradientError("missing proxy URL or Gradient slug; call whoami() first")
865
+ raw = f"{resolved_proxy}/{resolved_slug}/{machine_id}/"
866
+ if not proxy_session_token:
867
+ return raw
868
+ parsed = urlparse(raw)
869
+ query = dict(parse_qsl(parsed.query, keep_blank_values=True))
870
+ query["gradient_proxy_token"] = proxy_session_token
871
+ return urlunparse(parsed._replace(query=urlencode(query)))
872
+
873
+ def _call(
874
+ self,
875
+ method: str,
876
+ path: str,
877
+ body: Any | None = None,
878
+ *,
879
+ auth_token: str | None = None,
880
+ ) -> Any:
881
+ raw_body: bytes | None = None
882
+ if body is not None:
883
+ raw_body = json.dumps(body).encode("utf-8")
884
+
885
+ req = Request(
886
+ self.api_url + path,
887
+ data=raw_body,
888
+ method=method,
889
+ headers={
890
+ "Authorization": f"Bearer {auth_token or self.api_key}",
891
+ "Content-Type": "application/json",
892
+ "Accept": "application/json",
893
+ "User-Agent": "gradient-sdk-python/0.2.0",
894
+ },
895
+ )
896
+
897
+ status, headers, raw = self._send(req)
898
+ self.last_server_timing = headers.get("Server-Timing", "")
899
+
900
+ if status == 401:
901
+ raise GradientAuthError(
902
+ "unauthorized. Your API key is invalid or revoked; run `gradient login` again."
903
+ )
904
+ if status < 200 or status >= 300:
905
+ message = raw.decode("utf-8", errors="replace")
906
+ fly_request_id = None
907
+ try:
908
+ err_data = json.loads(message)
909
+ message = str(err_data.get("error") or message)
910
+ fly_request_id = err_data.get("fly_request_id")
911
+ except json.JSONDecodeError:
912
+ pass
913
+ raise GradientAPIError(
914
+ status,
915
+ method,
916
+ path,
917
+ message,
918
+ fly_request_id=fly_request_id,
919
+ response_body=raw.decode("utf-8", errors="replace"),
920
+ )
921
+
922
+ if not raw:
923
+ return None
924
+ decoded = json.loads(raw.decode("utf-8"))
925
+ if isinstance(decoded, Mapping) and "data" in decoded:
926
+ return decoded["data"]
927
+ return decoded
928
+
929
+ def _send(self, req: Request) -> tuple[int, Mapping[str, str], bytes]:
930
+ try:
931
+ res = self._transport(req, self.timeout) if self._transport else urlopen(req, timeout=self.timeout)
932
+ with res:
933
+ status = int(getattr(res, "status", getattr(res, "code", 0)))
934
+ headers = dict(res.headers.items())
935
+ raw = res.read()
936
+ return status, headers, raw
937
+ except HTTPError as exc:
938
+ headers = dict(exc.headers.items())
939
+ return int(exc.code), headers, exc.read()
940
+ except URLError as exc:
941
+ raise GradientError(f"failed to reach Gradient API: {exc.reason}") from exc
942
+
943
+
944
+ def default_machine_template(
945
+ image: str,
946
+ *,
947
+ region: str = "sjc",
948
+ size: str = "shared-cpu-2x",
949
+ memory_mb: int = 1024,
950
+ env: Mapping[str, str] | None = None,
951
+ services: list[ServiceConfig] | list[dict[str, Any]] | None = None,
952
+ metadata: Mapping[str, str] | None = None,
953
+ project: str | None = None,
954
+ ) -> MachineCreateRequest:
955
+ """Create a machine template matching the Gradient CLI defaults."""
956
+
957
+ cpu_kind = "performance" if size.startswith("performance") else "shared"
958
+ cpus = 2
959
+ match = re.search(r"-(\d+)x$", size)
960
+ if match:
961
+ cpus = int(match.group(1))
962
+ resolved_metadata = dict(metadata or {})
963
+ if project:
964
+ resolved_metadata["project"] = project
965
+ return MachineCreateRequest(
966
+ region=region,
967
+ config=MachineSpec(
968
+ image=image,
969
+ guest=Guest(cpu_kind=cpu_kind, cpus=cpus, memory_mb=memory_mb),
970
+ env=dict(env or {}),
971
+ services=services or [ServiceConfig.default_https()],
972
+ restart=Restart(policy="no"),
973
+ metadata=resolved_metadata or None,
974
+ ),
975
+ )
976
+
977
+
978
+ def _payload(value: Any) -> Any:
979
+ if is_dataclass(value):
980
+ return to_payload(asdict(value))
981
+ return to_payload(value)
982
+
983
+
984
+ def _with_trace_env(
985
+ template: MachineCreateRequest | Mapping[str, Any],
986
+ trace: CreateTraceRunResponse,
987
+ api_url: str,
988
+ ) -> dict[str, Any]:
989
+ payload = _payload(template)
990
+ config = dict(payload.get("config", {}))
991
+ env = dict(config.get("env") or {})
992
+ env.update(
993
+ {
994
+ "GRADIENT_TRACE_RUN_ID": trace.id,
995
+ "GRADIENT_TRACE_TOKEN": trace.ingest_token,
996
+ "GRADIENT_TRACE_ENDPOINT": trace.ingest_url or api_url.rstrip("/") + "/api/v1/runs/ingest",
997
+ "GRADIENT_TRACE_MODE": trace.trace_mode,
998
+ }
999
+ )
1000
+ env.setdefault(
1001
+ "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
1002
+ api_url.rstrip("/") + "/v1/traces",
1003
+ )
1004
+ env.setdefault("OTEL_EXPORTER_OTLP_PROTOCOL", "http/json")
1005
+ env["OTEL_EXPORTER_OTLP_HEADERS"] = _append_env_list(
1006
+ env.get("OTEL_EXPORTER_OTLP_HEADERS", ""),
1007
+ f"authorization={quote('Bearer ' + trace.ingest_token, safe='')}",
1008
+ )
1009
+ env["OTEL_RESOURCE_ATTRIBUTES"] = _append_env_list(
1010
+ env.get("OTEL_RESOURCE_ATTRIBUTES", ""),
1011
+ f"gradient.run.id={trace.id}",
1012
+ f"gradient.trace.mode={trace.trace_mode}",
1013
+ )
1014
+ metadata = dict(config.get("metadata") or {})
1015
+ metadata.update({"trace_run_id": trace.id, "trace_mode": trace.trace_mode})
1016
+ config["env"] = env
1017
+ config["metadata"] = metadata
1018
+ payload["config"] = config
1019
+ return payload
1020
+
1021
+
1022
+ def _with_replay_env(
1023
+ template: Mapping[str, Any],
1024
+ replay_config: Mapping[str, Any],
1025
+ ) -> dict[str, Any]:
1026
+ payload = _payload(template)
1027
+ config = dict(payload.get("config", {}))
1028
+ env = dict(config.get("env") or {})
1029
+
1030
+ def _stringify(value: Any) -> str:
1031
+ if isinstance(value, bool):
1032
+ return "true" if value else "false"
1033
+ if isinstance(value, float) and value.is_integer():
1034
+ return str(int(value))
1035
+ return str(value)
1036
+
1037
+ egress = replay_config.get("egress") or "live"
1038
+ env["GRADIENT_REPLAY_EGRESS"] = _stringify(egress)
1039
+ mapping = {
1040
+ "parentRunId": "GRADIENT_REPLAY_PARENT_RUN_ID",
1041
+ "seed": "GRADIENT_REPLAY_SEED",
1042
+ "freezeTime": "GRADIENT_REPLAY_FREEZE_TIME",
1043
+ "datasetId": "GRADIENT_REPLAY_DATASET_ID",
1044
+ }
1045
+ for key, env_key in mapping.items():
1046
+ value = replay_config.get(key)
1047
+ if value not in (None, ""):
1048
+ env[env_key] = _stringify(value)
1049
+ config["env"] = env
1050
+ payload["config"] = config
1051
+ return payload
1052
+
1053
+
1054
+ def _append_env_list(current: str, *values: str) -> str:
1055
+ items = [item for item in current.split(",") if item]
1056
+ for value in values:
1057
+ if value and value not in items:
1058
+ items.append(value)
1059
+ return ",".join(items)