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/__init__.py +122 -0
- gradient_sdk/agent.py +368 -0
- gradient_sdk/client.py +1059 -0
- gradient_sdk/context.py +91 -0
- gradient_sdk/credentials.py +92 -0
- gradient_sdk/decorators.py +202 -0
- gradient_sdk/errors.py +30 -0
- gradient_sdk/graph.py +190 -0
- gradient_sdk/integrations/__init__.py +7 -0
- gradient_sdk/integrations/agent.py +263 -0
- gradient_sdk/integrations/anthropic.py +51 -0
- gradient_sdk/integrations/openai.py +105 -0
- gradient_sdk/model_catalog.py +75 -0
- gradient_sdk/models.py +370 -0
- gradient_sdk/py.typed +1 -0
- gradient_sdk/runtime.py +1474 -0
- gradient_sdk/session.py +132 -0
- gradient_sdk/tools.py +156 -0
- gradient_sdk/tracing.py +361 -0
- usegradient-1.3.1.dist-info/METADATA +235 -0
- usegradient-1.3.1.dist-info/RECORD +22 -0
- usegradient-1.3.1.dist-info/WHEEL +4 -0
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)
|