trodo-python 2.26.1__py3-none-any.whl → 3.0.0__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.
trodo/__init__.py CHANGED
@@ -41,7 +41,7 @@ Downstream microservice (join the caller's run instead of making a new one):
41
41
 
42
42
  from __future__ import annotations
43
43
 
44
- __version__ = "2.26.1"
44
+ __version__ = "2.26.2"
45
45
 
46
46
  from typing import Any, Callable, Dict, List, Optional, Union
47
47
 
@@ -120,15 +120,10 @@ __all__ = [
120
120
  "TemplateError",
121
121
  "ManagedPrompt",
122
122
  "PromptSummary",
123
- # Datasets & experiments
123
+ # Datasets
124
+ "datasets",
124
125
  "append_dataset",
125
126
  "export_dataset",
126
- "ingest_experiment",
127
- "create_experiment",
128
- "run_experiment",
129
- "get_experiment",
130
- "wait_experiment",
131
- "compare_experiments",
132
127
  ]
133
128
 
134
129
  # ============================================================================
@@ -412,9 +407,30 @@ def render_template(
412
407
 
413
408
 
414
409
  # ----------------------------------------------------------------------------
415
- # Datasets & experiments ingest
410
+ # Datasets
416
411
  # ----------------------------------------------------------------------------
417
412
 
413
+ def datasets(api_key: Optional[str] = None, api_url: Optional[str] = None) -> Any:
414
+ """The team's datasets, managed with a Trodo API key (``TRODO_API_KEY``), as the CLI does::
415
+
416
+ ds = trodo.datasets()
417
+ ds.create("refund regressions", items=[{"input": "Refund order 42?", "expected": {"refund": True}}])
418
+ for row in ds.rows("refund regressions", rows="failed"):
419
+ ...
420
+ ds.update("refund regressions", [{"id": row["id"], "expected": "…"}])
421
+ ds.archive("refund regressions", [dup_id]) # restore() brings it back
422
+ ds.add_columns("refund regressions", ["lang"])
423
+
424
+ Works without ``trodo.init``; after it, ``append`` (site id) works here too.
425
+ Nothing deletes.
426
+ """
427
+ from .managers.dataset_manager import DatasetManager
428
+ from .api.management import ManagementClient
429
+ if api_key is None and api_url is None and _client is not None:
430
+ return _client.datasets
431
+ return DatasetManager(_client._http if _client is not None else None, ManagementClient(api_key, api_url))
432
+
433
+
418
434
  def append_dataset(
419
435
  ref: str,
420
436
  items: Union[Dict[str, Any], List[Dict[str, Any]]],
@@ -460,87 +476,6 @@ def export_dataset(
460
476
  )
461
477
 
462
478
 
463
- def ingest_experiment(
464
- dataset: str,
465
- outputs: List[Dict[str, Any]],
466
- name: Optional[str] = None,
467
- task_name: Optional[str] = None,
468
- judge: Optional[Dict[str, Any]] = None,
469
- evaluator_ids: Optional[List[str]] = None,
470
- dataset_version_no: Optional[int] = None,
471
- ) -> Dict[str, Any]:
472
- """Ingest a batch of model *outputs* for *dataset* and grade them.
473
-
474
- *outputs* is a list of dicts shaped ``{"item_position": int, "output": str,
475
- "expected_output"?, "query"?, "context"?, "cost_usd"?, "latency_ms"?}``.
476
- Configure grading with ``judge`` (``{"credential_id", "provider", "model"}``)
477
- and/or ``evaluator_ids``::
478
-
479
- exp = trodo.ingest_experiment("support-eval", [
480
- {"item_position": 0, "output": "here is your tracking link"},
481
- ], judge={"credential_id": "cred_1", "provider": "openai", "model": "gpt-4o"})
482
-
483
- Returns the experiment dict.
484
- """
485
- return _get_client().experiments.ingest(
486
- dataset,
487
- outputs,
488
- name=name,
489
- task_name=task_name,
490
- judge=judge,
491
- evaluator_ids=evaluator_ids,
492
- dataset_version_no=dataset_version_no,
493
- )
494
-
495
-
496
- def create_experiment(
497
- *,
498
- dataset: Optional[str] = None,
499
- dataset_id: Optional[str] = None,
500
- models: List[Dict[str, Any]],
501
- **kwargs: Any,
502
- ) -> Dict[str, Any]:
503
- """Create an experiment (immutable refs, status pending)."""
504
- return _get_client().experiments.create(
505
- dataset=dataset, dataset_id=dataset_id, models=models, **kwargs
506
- )
507
-
508
-
509
- def run_experiment(
510
- *,
511
- dataset: Optional[str] = None,
512
- dataset_id: Optional[str] = None,
513
- models: List[Dict[str, Any]],
514
- **kwargs: Any,
515
- ) -> Dict[str, Any]:
516
- """Create and run an experiment synchronously."""
517
- return _get_client().experiments.run(
518
- dataset=dataset, dataset_id=dataset_id, models=models, **kwargs
519
- )
520
-
521
-
522
- def get_experiment(experiment_id: str) -> Dict[str, Any]:
523
- """Fetch an experiment by id."""
524
- return _get_client().experiments.get(experiment_id)
525
-
526
-
527
- def wait_experiment(
528
- experiment_id: str,
529
- *,
530
- interval_ms: int = 2500,
531
- timeout_ms: int = 600_000,
532
- ) -> Dict[str, Any]:
533
- """Poll until the experiment completes or fails."""
534
- return _get_client().experiments.wait(
535
- experiment_id, interval_ms=interval_ms, timeout_ms=timeout_ms
536
- )
537
-
538
-
539
- def compare_experiments(a_id: str, b_id: str, *, strict: bool = False) -> Dict[str, Any]:
540
- """Compare two experiments side by side."""
541
- return _get_client().experiments.compare(a_id, b_id, strict=strict)
542
-
543
-
544
479
  def enable_auto_events() -> None:
545
480
  _get_client().enable_auto_events()
546
481
 
trodo/_version.py CHANGED
@@ -1 +1 @@
1
- SDK_VERSION = "2.26.0"
1
+ SDK_VERSION = "3.0.0"
trodo/api/endpoints.py CHANGED
@@ -28,10 +28,5 @@ RUNS_START = "/api/sdk/runs/start"
28
28
  RUNS_BASE = "/api/sdk/runs" # /runs/{run_id}/end, /spans, /feedback
29
29
  SPANS_APPEND = "/api/sdk/spans/append"
30
30
  OTLP_TRACES = "/api/sdk/otel/v1/traces"
31
- # Datasets & experiments
31
+ # Datasets
32
32
  DATASETS_BASE = "/api/sdk/datasets" # /datasets/{ref}/items
33
- EXPERIMENTS = "/api/sdk/experiments"
34
- EXPERIMENTS_INGEST = "/api/sdk/experiments/ingest"
35
- EXPERIMENTS_CREATE = "/api/sdk/experiments/create"
36
- EXPERIMENTS_RUN = "/api/sdk/experiments/run"
37
- EXPERIMENTS_COMPARE = "/api/sdk/experiments/compare"
trodo/api/http_client.py CHANGED
@@ -219,31 +219,6 @@ class HttpClient:
219
219
  "version": int(version) if version and version.isdigit() else None,
220
220
  }
221
221
 
222
- def ingest_experiment(self, body: Dict[str, Any]) -> ApiResult:
223
- return self._request("/api/sdk/experiments/ingest", body)
224
-
225
- def create_experiment(self, body: Dict[str, Any]) -> ApiResult:
226
- return self._request("/api/sdk/experiments/create", body)
227
-
228
- def run_experiment(self, body: Dict[str, Any]) -> ApiResult:
229
- return self._request("/api/sdk/experiments/run", body)
230
-
231
- def run_experiment_by_id(self, experiment_id: str) -> ApiResult:
232
- from urllib.parse import quote
233
- return self._request(f"/api/sdk/experiments/{quote(str(experiment_id), safe='')}/run", {})
234
-
235
- def get_experiment(self, experiment_id: str) -> ApiResult:
236
- from urllib.parse import quote
237
- return self._get(f"/api/sdk/experiments/{quote(str(experiment_id), safe='')}")
238
-
239
- def compare_experiments(
240
- self, a_id: str, b_id: str, strict: bool = False
241
- ) -> ApiResult:
242
- params: Dict[str, Any] = {"a": a_id, "b": b_id}
243
- if strict:
244
- params["strict"] = "true"
245
- return self._get("/api/sdk/experiments/compare", params)
246
-
247
222
  def post_track(self, session_data: Dict[str, Any]) -> ApiResult:
248
223
  return self._request("/api/sdk/track", {"sessionData": session_data})
249
224
 
@@ -0,0 +1,90 @@
1
+ """The team API (/api/v1) with a Trodo API key — what the CLI uses.
2
+
3
+ Dataset management (create, read rows, edit, archive, add columns) goes
4
+ through here, never through the site id: a site id can sit in a browser page,
5
+ and nobody who only has that should be able to change a dataset. The key is a
6
+ Trodo API key (Settings → MCP, or ``trodo login``), passed in or read from
7
+ ``TRODO_API_KEY``; ``TRODO_API_URL`` overrides the address.
8
+
9
+ Reads retry on network errors and 502/503/504; writes are sent once.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import time
17
+ from typing import Any, Dict, Iterator, Optional
18
+
19
+ import requests
20
+
21
+ from .._version import SDK_VERSION
22
+
23
+ DEFAULT_API_URL = "https://api.trodo.ai"
24
+
25
+
26
+ class TrodoApiError(RuntimeError):
27
+ """The API refused or failed. ``status`` and ``body`` say why."""
28
+
29
+ def __init__(self, message: str, status: Optional[int] = None, body: Any = None) -> None:
30
+ super().__init__(message)
31
+ self.status = status
32
+ self.body = body
33
+
34
+
35
+ class ManagementClient:
36
+ def __init__(self, api_key: Optional[str] = None, api_url: Optional[str] = None, timeout: int = 60) -> None:
37
+ key = api_key or os.environ.get("TRODO_API_KEY")
38
+ if not key:
39
+ raise ValueError(
40
+ "trodo: dataset management needs a Trodo API key: pass api_key= or set TRODO_API_KEY "
41
+ "(Settings → MCP, or `trodo login`). The site id cannot change datasets."
42
+ )
43
+ self.base = (api_url or os.environ.get("TRODO_API_URL") or DEFAULT_API_URL).rstrip("/") + "/api/v1"
44
+ self.timeout = timeout
45
+ self._session = requests.Session()
46
+ self._session.headers.update({
47
+ "Authorization": f"Bearer {key}",
48
+ "Content-Type": "application/json",
49
+ "User-Agent": f"trodo-python/{SDK_VERSION}",
50
+ })
51
+
52
+ def _send(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None,
53
+ body: Any = None, stream: bool = False) -> requests.Response:
54
+ url = self.base + path
55
+ clean = {k: v for k, v in (params or {}).items() if v is not None and v != ""}
56
+ tries = 4 if method == "GET" else 1
57
+ for attempt in range(tries):
58
+ try:
59
+ resp = self._session.request(method, url, params=clean, data=None if body is None else json.dumps(body),
60
+ timeout=self.timeout, stream=stream)
61
+ except requests.RequestException as exc:
62
+ if attempt + 1 < tries:
63
+ time.sleep(0.5 * (2 ** attempt))
64
+ continue
65
+ raise TrodoApiError(f"trodo: {method} {path} failed: {exc}") from exc
66
+ if resp.status_code in (502, 503, 504) and attempt + 1 < tries:
67
+ time.sleep(0.5 * (2 ** attempt))
68
+ continue
69
+ if resp.status_code >= 400:
70
+ try:
71
+ data = resp.json()
72
+ except ValueError:
73
+ data = {"message": resp.text[:300]}
74
+ msg = (data.get("message") or data.get("error")) if isinstance(data, dict) else None
75
+ raise TrodoApiError(f"trodo: {method} {path} → HTTP {resp.status_code} {msg or ''}".strip(), resp.status_code, data)
76
+ return resp
77
+ raise TrodoApiError(f"trodo: {method} {path} failed") # pragma: no cover
78
+
79
+ def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
80
+ return self._send("GET", path, params=params).json()
81
+
82
+ def post(self, path: str, body: Any = None) -> Any:
83
+ return self._send("POST", path, body=body if body is not None else {}).json()
84
+
85
+ def lines(self, path: str, params: Optional[Dict[str, Any]] = None) -> Iterator[Dict[str, Any]]:
86
+ """A JSON Lines response, one object at a time."""
87
+ resp = self._send("GET", path, params=params, stream=True)
88
+ for raw in resp.iter_lines(decode_unicode=True):
89
+ if raw and raw.strip():
90
+ yield json.loads(raw)
trodo/client.py CHANGED
@@ -84,7 +84,6 @@ class TrodoClient:
84
84
  self._session_manager = SessionManager(max_cached_users)
85
85
  self._prompts = None # lazily-built PromptManager
86
86
  self._datasets = None # lazily-built DatasetManager
87
- self._experiments = None # lazily-built ExperimentManager
88
87
  self._users = None # lazily-built UserManager
89
88
 
90
89
  if batch_enabled:
@@ -149,14 +148,6 @@ class TrodoClient:
149
148
  self._datasets = DatasetManager(self._http)
150
149
  return self._datasets
151
150
 
152
- @property
153
- def experiments(self):
154
- """Managed experiment runs + external ingest for server-side grading."""
155
- if self._experiments is None:
156
- from .managers.experiment_manager import ExperimentManager
157
- self._experiments = ExperimentManager(self._http)
158
- return self._experiments
159
-
160
151
  @property
161
152
  def users(self):
162
153
  """Create-or-update a user and their traits — the stateless-backend
@@ -1,6 +1,13 @@
1
- """Dataset ingest — append evaluation items to a Trodo dataset.
1
+ """Datasets — append rows with the site id; manage them with an API key.
2
2
 
3
- Datasets are the fixed inputs an experiment runs against: each item is a set of
3
+ ``append`` and ``export`` work as before (the site id, an export token).
4
+ Everything else — create, list, get, rows, update, archive, restore,
5
+ add_columns — goes through the team API with a Trodo API key, the one the
6
+ CLI uses (``TRODO_API_KEY``), because those change rows a site id must not
7
+ reach. Nothing here deletes: archive takes rows out of the working set,
8
+ the version before keeps them, and restore brings them back.
9
+
10
+ Datasets are fixed inputs to evaluate against: each item is a set of
4
11
  variable values (``input``) plus an optional gold answer (``expected_output``)
5
12
  and free-form ``metadata``. This manager appends items to a dataset addressed by
6
13
  UUID or by name; when a name is used and no such dataset exists it is
@@ -13,7 +20,7 @@ Mirrors ``sdks/trodo-node-sdk/src/managers/DatasetManager.ts`` in shape.
13
20
  from __future__ import annotations
14
21
 
15
22
  import json
16
- from typing import Any, Dict, List, Optional, Union
23
+ from typing import Any, Dict, Iterator, List, Optional, Union
17
24
 
18
25
  __all__ = ["DatasetManager"]
19
26
 
@@ -45,11 +52,157 @@ def _clean_item(item: Dict[str, Any]) -> Dict[str, Any]:
45
52
  return out
46
53
 
47
54
 
55
+ def _wire_row(r: Dict[str, Any]) -> Dict[str, Any]:
56
+ """A row for POST /datasets/:id/items: ``expected`` is sent as ``expected_output``."""
57
+ if not isinstance(r, dict):
58
+ raise ValueError("trodo: each dataset row must be a dict")
59
+ out = dict(r)
60
+ if "expected" in out and "expected_output" not in out:
61
+ out["expected_output"] = out.pop("expected")
62
+ return out
63
+
64
+
48
65
  class DatasetManager:
49
- """Append items to the team's evaluation datasets."""
66
+ """The team's datasets: append with the site id, manage with an API key."""
50
67
 
51
- def __init__(self, http_client: Any) -> None:
68
+ def __init__(self, http_client: Any, api: Any = None) -> None:
52
69
  self._http = http_client
70
+ self._api = api # a ManagementClient, built on first use
71
+
72
+ def _mgmt(self, api_key: Optional[str] = None, api_url: Optional[str] = None) -> Any:
73
+ if api_key or api_url:
74
+ from ..api.management import ManagementClient
75
+ return ManagementClient(api_key, api_url)
76
+ if self._api is None:
77
+ from ..api.management import ManagementClient
78
+ self._api = ManagementClient()
79
+ return self._api
80
+
81
+ def _resolve(self, api: Any, ref: str) -> Dict[str, Any]:
82
+ if not ref:
83
+ raise ValueError("trodo: name the dataset (its name or id)")
84
+ listed = api.get("/datasets").get("datasets") or []
85
+ low = str(ref).lower()
86
+ hit = next((d for d in listed if d.get("id") == ref), None) \
87
+ or next((d for d in listed if d.get("name") == ref), None) \
88
+ or next((d for d in listed if str(d.get("name", "")).lower() == low), None)
89
+ if not hit:
90
+ raise ValueError(f"trodo: no dataset {ref!r}")
91
+ return hit
92
+
93
+ def list(self, *, api_key: Optional[str] = None) -> List[Dict[str, Any]]:
94
+ """Every dataset of the key's team, with its row counts."""
95
+ return self._mgmt(api_key).get("/datasets").get("datasets") or []
96
+
97
+ def get(self, ref: str, *, api_key: Optional[str] = None) -> Dict[str, Any]:
98
+ """One dataset by name or id: its columns, derived columns and counts (not the rows)."""
99
+ api = self._mgmt(api_key)
100
+ d = self._resolve(api, ref)
101
+ return api.get(f"/datasets/{d['id']}", {"summary": 1}).get("dataset") or d
102
+
103
+ def create(self, name: str, *, description: Optional[str] = None,
104
+ items: Optional[List[Dict[str, Any]]] = None, api_key: Optional[str] = None) -> Dict[str, Any]:
105
+ """A new dataset, optionally with rows ({input, output?, expected?, metadata?} or {run_id} …).
106
+
107
+ Returns ``{"dataset": {...}, "written": int}``.
108
+ """
109
+ if not name or not str(name).strip():
110
+ raise ValueError("trodo: create(name) needs a name")
111
+ api = self._mgmt(api_key)
112
+ body: Dict[str, Any] = {"name": name}
113
+ if description:
114
+ body["description"] = description
115
+ dataset = api.post("/datasets", body).get("dataset") or {}
116
+ written = 0
117
+ if items:
118
+ for i in range(0, len(items), 500):
119
+ out = api.post(f"/datasets/{dataset['id']}/items", {"items": [_wire_row(r) for r in items[i:i + 500]]})
120
+ written += int(out.get("written", out.get("added", 0)) or 0)
121
+ return {"dataset": dataset, "written": written}
122
+
123
+ def rows(self, ref: str, *, rows: Optional[str] = None, eval: Optional[str] = None,
124
+ format: Optional[str] = None, target: Optional[str] = None, version: Optional[int] = None,
125
+ api_key: Optional[str] = None) -> Iterator[Dict[str, Any]]:
126
+ """The dataset's rows, one dict at a time — the same as ``trodo datasets export``.
127
+
128
+ *rows*: all, expected, no_expected, passed, failed, marked_wrong. *format*: trodo
129
+ (default), openai, dpo. *target* (openai): expected_else_output, output, expected.
130
+ Each Trodo row carries ``id``, so an edit can go straight back through ``update``.
131
+ """
132
+ api = self._mgmt(api_key)
133
+ d = self._resolve(api, ref)
134
+ params = {"rows": rows, "eval": eval, "format": format, "target": target, "version": version}
135
+ return api.lines(f"/datasets/{d['id']}/export", params)
136
+
137
+ def update(self, ref: str, updates: List[Dict[str, Any]], *, label: str = "person",
138
+ version_name: Optional[str] = None, api_key: Optional[str] = None) -> Dict[str, Any]:
139
+ """Edit rows by id: ``[{"id", "input"?, "expected"?, "output"?, "metadata"?, "tags"?}]``.
140
+
141
+ *input* is replaced the way rows are added (text, a list of messages, or an object
142
+ of variables); *expected* may be any JSON; *metadata* merges. One version per 5,000
143
+ rows. *label* ``"derived"`` marks a changed Expected as written by a rule or model.
144
+ Returns ``{"updated", "unchanged", "missing", "version"}``.
145
+ """
146
+ api = self._mgmt(api_key)
147
+ d = self._resolve(api, ref)
148
+ rows = [u for u in (updates or []) if isinstance(u, dict) and u.get("id")]
149
+ if not rows:
150
+ raise ValueError("trodo: update needs rows with an id")
151
+ total: Dict[str, Any] = {"updated": 0, "unchanged": 0, "missing": [], "version": None}
152
+ for i in range(0, len(rows), 5000):
153
+ body: Dict[str, Any] = {"updates": rows[i:i + 5000], "label": "derived" if label == "derived" else "person"}
154
+ if version_name:
155
+ body["version_name"] = version_name
156
+ out = api.post(f"/datasets/{d['id']}/items/update", body)
157
+ total["updated"] += int(out.get("updated") or 0)
158
+ total["unchanged"] += int(out.get("unchanged") or 0)
159
+ total["missing"] += out.get("missing") or []
160
+ total["version"] = out.get("version") or total["version"]
161
+ return total
162
+
163
+ def _move(self, kind: str, ref: str, ids: List[str], version_name: Optional[str], api_key: Optional[str]) -> Dict[str, Any]:
164
+ api = self._mgmt(api_key)
165
+ d = self._resolve(api, ref)
166
+ ids = [str(i) for i in (ids or []) if i]
167
+ if not ids:
168
+ raise ValueError(f"trodo: {kind} needs row ids")
169
+ key = "archived" if kind == "archive" else "restored"
170
+ total: Dict[str, Any] = {key: 0, "unchanged": 0, "version": None}
171
+ for i in range(0, len(ids), 5000):
172
+ body: Dict[str, Any] = {"ids": ids[i:i + 5000]}
173
+ if version_name:
174
+ body["version_name"] = version_name
175
+ out = api.post(f"/datasets/{d['id']}/items/{kind}", body)
176
+ total[key] += int(out.get(key) or 0)
177
+ total["unchanged"] += int(out.get("unchanged") or 0)
178
+ total["version"] = out.get("version") or total["version"]
179
+ return total
180
+
181
+ def archive(self, ref: str, ids: List[str], *, version_name: Optional[str] = None,
182
+ api_key: Optional[str] = None) -> Dict[str, Any]:
183
+ """Take rows out of the working set (exports, labeling and sources skip them).
184
+
185
+ Nothing is deleted: the version before holds them and ``restore`` brings them back.
186
+ """
187
+ return self._move("archive", ref, ids, version_name, api_key)
188
+
189
+ def restore(self, ref: str, ids: List[str], *, version_name: Optional[str] = None,
190
+ api_key: Optional[str] = None) -> Dict[str, Any]:
191
+ """Bring archived rows back into the working set."""
192
+ return self._move("restore", ref, ids, version_name, api_key)
193
+
194
+ def add_columns(self, ref: str, columns: Optional[List[str]] = None, *,
195
+ derived: Optional[List[Dict[str, str]]] = None, api_key: Optional[str] = None) -> Dict[str, Any]:
196
+ """Add input columns by name, and derived columns ``{"name", "path"}`` or ``{"name", "expr"}``.
197
+
198
+ Columns are only ever added; a name already there is left as it is.
199
+ Returns ``{"added", "columns", "derived_columns"}``.
200
+ """
201
+ if not columns and not derived:
202
+ raise ValueError("trodo: add_columns needs columns or derived")
203
+ api = self._mgmt(api_key)
204
+ d = self._resolve(api, ref)
205
+ return api.post(f"/datasets/{d['id']}/columns/add", {"columns": list(columns or []), "derived": list(derived or [])})
53
206
 
54
207
  def append(
55
208
  self,
trodo/otel/processor.py CHANGED
@@ -11,6 +11,9 @@ import threading
11
11
  from dataclasses import dataclass, asdict
12
12
  from typing import Any, Dict, List, Optional, Union
13
13
 
14
+ # How long end_run waits for the run's queued spans to send before sending the end.
15
+ END_RUN_FLUSH_S = 5.0
16
+
14
17
 
15
18
  @dataclass
16
19
  class TrodoRun:
@@ -189,7 +192,21 @@ class TrodoSpanProcessor:
189
192
  pass
190
193
 
191
194
  def end_run(self, run_id: str, payload: Dict[str, Any]) -> None:
192
- """Finalise a Run opened by start_run."""
195
+ """Finalise a Run opened by start_run.
196
+
197
+ The run's spans go first: joined spans wait in the sender's queue, and
198
+ an end that overtakes them gets the run evaluated on a partial trace.
199
+ Bounded, and a failure here never holds up the end.
200
+ """
201
+ try:
202
+ with self._lock:
203
+ buffered = self._pending.pop(run_id, None) or []
204
+ if buffered:
205
+ self._send_spans(run_id, [s.to_dict() for s in buffered])
206
+ if self._sender is not None:
207
+ self._sender.flush(END_RUN_FLUSH_S)
208
+ except Exception:
209
+ pass
193
210
  try:
194
211
  self._http.post_run_end(run_id, payload)
195
212
  except Exception:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trodo-python
3
- Version: 2.26.1
3
+ Version: 3.0.0
4
4
  Summary: Trodo Analytics SDK for Python — server-side event tracking
5
5
  License: ISC
6
6
  Keywords: analytics,tracking,trodo,server-side
@@ -1,20 +1,20 @@
1
- trodo/__init__.py,sha256=RCoNO_seZkIUmweHvyKb8t6J5ydktoAHOg2nt2sp448,28752
2
- trodo/_version.py,sha256=MX7IGM5dYbXPeFIXOWPhV7DPbCslhEe8Q7DCvxwKmhY,23
3
- trodo/client.py,sha256=6k6HeKrGV2720kK17--u6BjbhAUFZNUzpd9yEYiaQOs,24137
1
+ trodo/__init__.py,sha256=x7zE63FK8CFJ6ATUvsXxeTkvZUaLKXqAJr99I6nnyZc,27099
2
+ trodo/_version.py,sha256=AsSlr9XWZaS6NVC4bCgB5xuBv1s5UrEYJlMsyJQfpMw,22
3
+ trodo/client.py,sha256=eaZtHMtOI-VLgfoqM_KVWMuP_I5Ivs7VFnR0oU96Yqk,23743
4
4
  trodo/types.py,sha256=eySgUvCXROG2TxtxgiU0MNr5iH0DEcduK8bmYtTKG44,3138
5
5
  trodo/user_context.py,sha256=uHCI2WYoOI3cNwdIUEuZdX4VYSu14pzm348ksn6hn_c,8195
6
6
  trodo/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
7
  trodo/api/_retry.py,sha256=mHhuzfe3QuYa6j9BqRYVClvnYH0o3WkXXngmSwqWfgY,1214
8
8
  trodo/api/async_client.py,sha256=qF97NbvoYbNhem8uXtPh14EG4JVAhEAzUCYn7busaaA,3609
9
- trodo/api/endpoints.py,sha256=SGgVELcVfwuCrRwlVPW2j0KYkaMtwLFPpdMtGtlqPeA,1574
10
- trodo/api/http_client.py,sha256=iOG9D4ackVAz0nQD5ZjwfIvRiEmgxga9uXgxrRQX6L4,13185
9
+ trodo/api/endpoints.py,sha256=jToYE2u5EWbkVLiRnEEGRAPtE-EbjNFMPvSFF-EfVt4,1323
10
+ trodo/api/http_client.py,sha256=kNqa18LuDC0f3eQFN3PQJk4I2PCB1McfSmRP_U_FuQQ,12103
11
+ trodo/api/management.py,sha256=uwhr0ZI1pvguz5-itW1KcgRj0TXM2n4KDa-hGIaBdTA,4023
11
12
  trodo/auto/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
13
  trodo/auto/auto_event_manager.py,sha256=cztuRsRkNoJE5R4NfSfTrTJTGl4jx2Yb-Ncy0aVAPo8,4247
13
14
  trodo/delivery/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
15
  trodo/delivery/sender.py,sha256=urO7m6mRMnSEW7_cNAmJ4A6jtDNxuihZHOxCx1jQScI,21348
15
16
  trodo/managers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
- trodo/managers/dataset_manager.py,sha256=mifHYkHLWo0olXCNfQOqvpLPL8DDgbub7YAWWlrjIHQ,5609
17
- trodo/managers/experiment_manager.py,sha256=V-vemLtenfSI5VIcP0HPiXzEy8AyTrRrbJVCA9Z6wjo,10579
17
+ trodo/managers/dataset_manager.py,sha256=HTFszK-wKmwjW-aw7xJWr5EKvDpJ_om496MLDr3gelg,13920
18
18
  trodo/managers/group_manager.py,sha256=ki3Se3qEoSZfREX63oeDeBmEfZF-ISHLE8azEtLg0tM,3542
19
19
  trodo/managers/people_manager.py,sha256=mMVnx40Mlifx6NGgChvohC9ViK6dQu2mkXNHbV8pK1E,2882
20
20
  trodo/managers/prompt_manager.py,sha256=qFVHgh7PW-SlgHp62ybirdYsJBg-fcQVMTTU3jPqFvo,13435
@@ -23,7 +23,7 @@ trodo/otel/__init__.py,sha256=yiRFXWUU45bAM2CV37XeO7zf1hmnmjufdP4XO50yEyE,624
23
23
  trodo/otel/auto_instrument.py,sha256=M5YVpFoPqn9bQohDFM7yyADrVGJPqFBToJLnBO7nOSA,29029
24
24
  trodo/otel/context.py,sha256=Jd0aTc0Q-1dM5kXXinhZD8YtnpdKgvneNiODyboVGKY,1418
25
25
  trodo/otel/helpers.py,sha256=XOMWcgZHaq5SQbkFxDaXPE4CDFjn01xjJmJ1vIxvwpw,20730
26
- trodo/otel/processor.py,sha256=WgtLdXT9gpR4TZ_9iuoX-kBP4UN4nelCtoDnogtW_Qc,8289
26
+ trodo/otel/processor.py,sha256=GiMQR3uKHq_sbqXU00MdyarStaBzrZLmPgU1YnlzZb4,8965
27
27
  trodo/otel/prompt_trace.py,sha256=-kywRBU0Q2gWntS9HU-P5i7Kc5U-IJU7UVLjkdC2jw4,13593
28
28
  trodo/otel/register.py,sha256=7X3AfFQ5YvHGOUmdq8J38LQGvOJFPD907yT3uAp90lI,9607
29
29
  trodo/otel/transport.py,sha256=hzZz8gwSMGJ8CxdijmLn1Ljt18owr9XTWy13DLbwYbw,2441
@@ -40,7 +40,7 @@ trodo/session/server_session.py,sha256=McsudEiq33XDq3nqxgzBcUvIjQxCMscwEuAPnYXrT
40
40
  trodo/session/session_manager.py,sha256=7ht5LeeZX1HLLfPeNV_a8NkXGbHl3up0CRtCGr3EzjQ,2995
41
41
  trodo/util/__init__.py,sha256=Z9c4rPPdKg06Kk3byKheDqksSgR4WNvS475oQ5sNljc,54
42
42
  trodo/util/lru.py,sha256=QIsM7s6J_E9ZjiXatSyReZIEeGypTAcgrPWWy8YsRa4,2465
43
- trodo_python-2.26.1.dist-info/METADATA,sha256=2I7mNSS6gC_w6I3Mc9iOT-E6VHLboXBB9o8i4F-8YN0,25308
44
- trodo_python-2.26.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
45
- trodo_python-2.26.1.dist-info/top_level.txt,sha256=VCQu1CJWFmNsqTs1YxMcw4Mq35Tc3z3uI9RwHEXAayQ,6
46
- trodo_python-2.26.1.dist-info/RECORD,,
43
+ trodo_python-3.0.0.dist-info/METADATA,sha256=Uq0WsyxJPpE-A8CBpSrafzEvniEf2a__kDIEE9055Ls,25307
44
+ trodo_python-3.0.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
45
+ trodo_python-3.0.0.dist-info/top_level.txt,sha256=VCQu1CJWFmNsqTs1YxMcw4Mq35Tc3z3uI9RwHEXAayQ,6
46
+ trodo_python-3.0.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (84.0.0)
2
+ Generator: setuptools (84.2.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,286 +0,0 @@
1
- """Experiment manager — managed runs and external ingest.
2
-
3
- Managed experiments (prompt + model over a dataset snapshot) use
4
- ``create``, ``run``, ``get``, ``wait``, and ``compare``.
5
- External agent outputs use ``ingest``.
6
-
7
- Mirrors ``sdks/trodo-node-sdk/src/managers/ExperimentManager.ts``.
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- import time
13
- from typing import Any, Dict, List, Optional, Union
14
-
15
- __all__ = ["ExperimentManager"]
16
-
17
- _TERMINAL = frozenset({"completed", "failed"})
18
-
19
- # Optional per-output fields for ingest
20
- _OUTPUT_OPTIONAL = (
21
- "item_position",
22
- "dataset_item_id",
23
- "expected_output",
24
- "query",
25
- "context",
26
- "cost_usd",
27
- "latency_ms",
28
- )
29
-
30
-
31
- def _clean_output(output: Dict[str, Any]) -> Dict[str, Any]:
32
- if not isinstance(output, dict):
33
- raise ValueError("trodo: each experiment output must be a dict")
34
- if output.get("output") is None:
35
- raise ValueError("trodo: each output requires an 'output' (str)")
36
- if output.get("item_position") is None and output.get("dataset_item_id") is None:
37
- raise ValueError(
38
- "trodo: each output requires 'item_position' and/or 'dataset_item_id'"
39
- )
40
- out: Dict[str, Any] = {"output": output["output"]}
41
- for key in _OUTPUT_OPTIONAL:
42
- if output.get(key) is not None:
43
- out[key] = output[key]
44
- return out
45
-
46
-
47
- def _unwrap_experiment(res: Any) -> Dict[str, Any]:
48
- if isinstance(res, dict) and "experiment" in res:
49
- return res["experiment"]
50
- return res if isinstance(res, dict) else {}
51
-
52
-
53
- def _build_managed_body(
54
- *,
55
- dataset: Optional[str] = None,
56
- dataset_id: Optional[str] = None,
57
- dataset_version_no: Optional[int] = None,
58
- name: Optional[str] = None,
59
- prompt_ref: Optional[Dict[str, Any]] = None,
60
- prompt_version_id: Optional[str] = None,
61
- prompt_id: Optional[str] = None,
62
- use_prompt: Optional[bool] = None,
63
- models: Optional[List[Dict[str, Any]]] = None,
64
- scorer_ids: Optional[List[Union[str, int]]] = None,
65
- evaluator_ids: Optional[List[Union[str, int]]] = None,
66
- deep_eval: Optional[bool] = None,
67
- stability_runs: Optional[int] = None,
68
- row_limit: Optional[int] = None,
69
- concurrency: Optional[int] = None,
70
- metadata: Optional[Dict[str, Any]] = None,
71
- ) -> Dict[str, Any]:
72
- body: Dict[str, Any] = {"models": models or []}
73
- ds = dataset_id or dataset
74
- if ds:
75
- body["dataset_id"] = ds
76
- if dataset_version_no is not None:
77
- body["dataset_version_no"] = dataset_version_no
78
- if name is not None:
79
- body["name"] = name
80
- if prompt_ref is not None:
81
- body["prompt_ref"] = prompt_ref
82
- if prompt_version_id is not None:
83
- body["prompt_version_id"] = prompt_version_id
84
- if prompt_id is not None:
85
- body["prompt_id"] = prompt_id
86
- if use_prompt is False:
87
- body["use_prompt"] = False
88
- ids = scorer_ids if scorer_ids is not None else evaluator_ids
89
- if ids is not None:
90
- body["scorer_ids"] = ids
91
- body["evaluator_ids"] = ids
92
- if deep_eval is not None:
93
- body["deep_eval"] = deep_eval
94
- if stability_runs is not None:
95
- body["stability_runs"] = stability_runs
96
- if row_limit is not None:
97
- body["row_limit"] = row_limit
98
- if concurrency is not None:
99
- body["concurrency"] = concurrency
100
- if metadata is not None:
101
- body["metadata"] = metadata
102
- return body
103
-
104
-
105
- class ExperimentManager:
106
- """Run and inspect Trodo experiments programmatically."""
107
-
108
- def __init__(self, http_client: Any) -> None:
109
- self._http = http_client
110
-
111
- def ingest(
112
- self,
113
- dataset: str,
114
- outputs: List[Dict[str, Any]],
115
- name: Optional[str] = None,
116
- task_name: Optional[str] = None,
117
- judge: Optional[Dict[str, Any]] = None,
118
- evaluator_ids: Optional[List[str]] = None,
119
- dataset_version_no: Optional[int] = None,
120
- ) -> Dict[str, Any]:
121
- """Ingest externally-produced outputs and grade them (external task path)."""
122
- if not dataset:
123
- raise ValueError("trodo: ingest_experiment(dataset) requires a dataset")
124
- if not outputs:
125
- raise ValueError("trodo: ingest_experiment requires outputs")
126
-
127
- body: Dict[str, Any] = {
128
- "dataset": dataset,
129
- "outputs": [_clean_output(o) for o in outputs],
130
- }
131
- if name is not None:
132
- body["name"] = name
133
- if task_name is not None:
134
- body["task_name"] = task_name
135
- if judge is not None:
136
- body["judge"] = judge
137
- if evaluator_ids is not None:
138
- body["evaluator_ids"] = evaluator_ids
139
- if dataset_version_no is not None:
140
- body["dataset_version_no"] = dataset_version_no
141
-
142
- return _unwrap_experiment(self._http.ingest_experiment(body))
143
-
144
- def create(
145
- self,
146
- *,
147
- dataset: Optional[str] = None,
148
- dataset_id: Optional[str] = None,
149
- models: List[Dict[str, Any]],
150
- dataset_version_no: Optional[int] = None,
151
- name: Optional[str] = None,
152
- prompt_ref: Optional[Dict[str, Any]] = None,
153
- prompt_version_id: Optional[str] = None,
154
- prompt_id: Optional[str] = None,
155
- use_prompt: Optional[bool] = None,
156
- scorer_ids: Optional[List[Union[str, int]]] = None,
157
- evaluator_ids: Optional[List[Union[str, int]]] = None,
158
- deep_eval: Optional[bool] = None,
159
- stability_runs: Optional[int] = None,
160
- row_limit: Optional[int] = None,
161
- concurrency: Optional[int] = None,
162
- metadata: Optional[Dict[str, Any]] = None,
163
- ) -> Dict[str, Any]:
164
- """Create an experiment (immutable refs, status pending)."""
165
- self._validate_run_params(dataset, dataset_id, models)
166
- body = _build_managed_body(
167
- dataset=dataset,
168
- dataset_id=dataset_id,
169
- dataset_version_no=dataset_version_no,
170
- name=name,
171
- prompt_ref=prompt_ref,
172
- prompt_version_id=prompt_version_id,
173
- prompt_id=prompt_id,
174
- use_prompt=use_prompt,
175
- models=models,
176
- scorer_ids=scorer_ids,
177
- evaluator_ids=evaluator_ids,
178
- deep_eval=deep_eval,
179
- stability_runs=stability_runs,
180
- row_limit=row_limit,
181
- concurrency=concurrency,
182
- metadata=metadata,
183
- )
184
- return _unwrap_experiment(self._http.create_experiment(body))
185
-
186
- def run(
187
- self,
188
- *,
189
- dataset: Optional[str] = None,
190
- dataset_id: Optional[str] = None,
191
- models: List[Dict[str, Any]],
192
- dataset_version_no: Optional[int] = None,
193
- name: Optional[str] = None,
194
- prompt_ref: Optional[Dict[str, Any]] = None,
195
- prompt_version_id: Optional[str] = None,
196
- prompt_id: Optional[str] = None,
197
- use_prompt: Optional[bool] = None,
198
- scorer_ids: Optional[List[Union[str, int]]] = None,
199
- evaluator_ids: Optional[List[Union[str, int]]] = None,
200
- deep_eval: Optional[bool] = None,
201
- stability_runs: Optional[int] = None,
202
- row_limit: Optional[int] = None,
203
- concurrency: Optional[int] = None,
204
- metadata: Optional[Dict[str, Any]] = None,
205
- ) -> Dict[str, Any]:
206
- """Create and execute an experiment synchronously."""
207
- self._validate_run_params(dataset, dataset_id, models)
208
- body = _build_managed_body(
209
- dataset=dataset,
210
- dataset_id=dataset_id,
211
- dataset_version_no=dataset_version_no,
212
- name=name,
213
- prompt_ref=prompt_ref,
214
- prompt_version_id=prompt_version_id,
215
- prompt_id=prompt_id,
216
- use_prompt=use_prompt,
217
- models=models,
218
- scorer_ids=scorer_ids,
219
- evaluator_ids=evaluator_ids,
220
- deep_eval=deep_eval,
221
- stability_runs=stability_runs,
222
- row_limit=row_limit,
223
- concurrency=concurrency,
224
- metadata=metadata,
225
- )
226
- return _unwrap_experiment(self._http.run_experiment(body))
227
-
228
- def run_by_id(self, experiment_id: str) -> Dict[str, Any]:
229
- """Execute a pending experiment created via ``create``."""
230
- if not experiment_id:
231
- raise ValueError("trodo: run_by_id requires an experiment id")
232
- return _unwrap_experiment(self._http.run_experiment_by_id(experiment_id))
233
-
234
- def get(self, experiment_id: str) -> Dict[str, Any]:
235
- """Fetch an experiment by id."""
236
- if not experiment_id:
237
- raise ValueError("trodo: get requires an experiment id")
238
- res = self._http.get_experiment(experiment_id)
239
- if isinstance(res, dict) and res.get("__error"):
240
- raise RuntimeError(f"trodo: get failed (HTTP {res.get('status')})")
241
- return _unwrap_experiment(res)
242
-
243
- def wait(
244
- self,
245
- experiment_id: str,
246
- *,
247
- interval_ms: int = 2500,
248
- timeout_ms: int = 600_000,
249
- ) -> Dict[str, Any]:
250
- """Poll until the experiment reaches a terminal status."""
251
- deadline = time.monotonic() + (timeout_ms / 1000.0)
252
- while True:
253
- exp = self.get(experiment_id)
254
- status = str(exp.get("status") or "").lower()
255
- if status in _TERMINAL:
256
- return exp
257
- if time.monotonic() >= deadline:
258
- raise TimeoutError(
259
- f"trodo: wait timed out after {timeout_ms}ms (status: {status or 'unknown'})"
260
- )
261
- time.sleep(interval_ms / 1000.0)
262
-
263
- def compare(
264
- self, a_id: str, b_id: str, *, strict: bool = False
265
- ) -> Dict[str, Any]:
266
- """Compare two experiments side by side."""
267
- if not a_id or not b_id:
268
- raise ValueError("trodo: compare requires two experiment ids")
269
- res = self._http.compare_experiments(a_id, b_id, strict=strict)
270
- if isinstance(res, dict) and res.get("__error"):
271
- raise RuntimeError(f"trodo: compare failed (HTTP {res.get('status')})")
272
- return res if isinstance(res, dict) else {}
273
-
274
- @staticmethod
275
- def _validate_run_params(
276
- dataset: Optional[str],
277
- dataset_id: Optional[str],
278
- models: Optional[List[Dict[str, Any]]],
279
- ) -> None:
280
- if not models:
281
- raise ValueError("trodo: run requires a non-empty models list")
282
- if not dataset and not dataset_id:
283
- raise ValueError("trodo: run requires dataset or dataset_id")
284
- for model in models:
285
- if not model.get("credential_id") or not model.get("model"):
286
- raise ValueError("trodo: each model needs credential_id and model")