trodo-python 2.16.0__py3-none-any.whl → 2.18.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.16.0"
44
+ __version__ = "2.18.0"
45
45
 
46
46
  from typing import Any, Callable, Dict, List, Optional, Union
47
47
 
@@ -80,6 +80,7 @@ __all__ = [
80
80
  "for_user",
81
81
  "track",
82
82
  "identify",
83
+ "upsert_user",
83
84
  "people_set",
84
85
  "people_set_once",
85
86
  "wallet_address",
@@ -119,9 +120,14 @@ __all__ = [
119
120
  "TemplateError",
120
121
  "ManagedPrompt",
121
122
  "PromptSummary",
122
- # Datasets & experiments ingest
123
+ # Datasets & experiments
123
124
  "append_dataset",
124
125
  "ingest_experiment",
126
+ "create_experiment",
127
+ "run_experiment",
128
+ "get_experiment",
129
+ "wait_experiment",
130
+ "compare_experiments",
125
131
  ]
126
132
 
127
133
  # ============================================================================
@@ -251,6 +257,44 @@ def identify(identify_id: str, session_id: Optional[str] = None) -> UserContext:
251
257
  return _get_client().identify(identify_id, session_id)
252
258
 
253
259
 
260
+ def upsert_user(
261
+ distinct_id: str,
262
+ *,
263
+ properties: Optional[Dict[str, Any]] = None,
264
+ set_once: Optional[Dict[str, Any]] = None,
265
+ fixed_properties: Optional[Dict[str, Any]] = None,
266
+ ):
267
+ """Create-or-update a user and their traits in one idempotent call.
268
+
269
+ The stateless-backend primitive: a request handler knows who the user is and
270
+ a few things about them for the duration of one request, and this is how you
271
+ say so. Safe to call on every request.
272
+
273
+ trodo.upsert_user(
274
+ "user-42",
275
+ properties={"plan": "pro"},
276
+ set_once={"signup_date": "2026-01-04"},
277
+ fixed_properties={"last_location_country": "India"},
278
+ )
279
+
280
+ Every bag is optional — with none of them it still ensures the user exists,
281
+ which is useful at signup, before they have produced any agent runs.
282
+
283
+ ``fixed_properties`` writes the real profile columns the dashboard filters
284
+ on, limited to what a backend can honestly know (country, city). Device,
285
+ browser, OS, referrer and UTM are browser observations and come back in
286
+ ``skipped`` instead of being stored.
287
+
288
+ Mirrors ``trodo.users.upsert(...)`` in trodo-node.
289
+ """
290
+ return _get_client().upsert_user(
291
+ distinct_id,
292
+ properties=properties,
293
+ set_once=set_once,
294
+ fixed_properties=fixed_properties,
295
+ )
296
+
297
+
254
298
  def people_set(distinct_id: str, properties: Dict[str, Any]):
255
299
  """Set profile properties for a user — module-level convenience mirroring
256
300
  ``trodo.people.set(distinctId, props)`` in trodo-node. Equivalent to
@@ -405,6 +449,54 @@ def ingest_experiment(
405
449
  )
406
450
 
407
451
 
452
+ def create_experiment(
453
+ *,
454
+ dataset: Optional[str] = None,
455
+ dataset_id: Optional[str] = None,
456
+ models: List[Dict[str, Any]],
457
+ **kwargs: Any,
458
+ ) -> Dict[str, Any]:
459
+ """Create an experiment (immutable refs, status pending)."""
460
+ return _get_client().experiments.create(
461
+ dataset=dataset, dataset_id=dataset_id, models=models, **kwargs
462
+ )
463
+
464
+
465
+ def run_experiment(
466
+ *,
467
+ dataset: Optional[str] = None,
468
+ dataset_id: Optional[str] = None,
469
+ models: List[Dict[str, Any]],
470
+ **kwargs: Any,
471
+ ) -> Dict[str, Any]:
472
+ """Create and run an experiment synchronously."""
473
+ return _get_client().experiments.run(
474
+ dataset=dataset, dataset_id=dataset_id, models=models, **kwargs
475
+ )
476
+
477
+
478
+ def get_experiment(experiment_id: str) -> Dict[str, Any]:
479
+ """Fetch an experiment by id."""
480
+ return _get_client().experiments.get(experiment_id)
481
+
482
+
483
+ def wait_experiment(
484
+ experiment_id: str,
485
+ *,
486
+ interval_ms: int = 2500,
487
+ timeout_ms: int = 600_000,
488
+ ) -> Dict[str, Any]:
489
+ """Poll until the experiment completes or fails."""
490
+ return _get_client().experiments.wait(
491
+ experiment_id, interval_ms=interval_ms, timeout_ms=timeout_ms
492
+ )
493
+
494
+
495
+ def compare_experiments(a_id: str, b_id: str, *, strict: bool = False) -> Dict[str, Any]:
496
+ """Compare two experiments side by side."""
497
+ return _get_client().experiments.compare(a_id, b_id, strict=strict)
498
+
499
+
408
500
  def enable_auto_events() -> None:
409
501
  _get_client().enable_auto_events()
410
502
 
trodo/api/endpoints.py CHANGED
@@ -4,6 +4,8 @@ EVENTS_BULK = "/api/events/bulk"
4
4
  IDENTIFY = "/api/sdk/identify"
5
5
  WALLET_ADDRESS = "/api/sdk/wallet-address"
6
6
  RESET = "/api/sdk/reset"
7
+ # Identity + traits in one idempotent call.
8
+ USERS_UPSERT = "/api/sdk/users/upsert"
7
9
  PEOPLE_SET = "/api/sdk/people/set"
8
10
  PEOPLE_SET_ONCE = "/api/sdk/people/set_once"
9
11
  PEOPLE_UNSET = "/api/sdk/people/unset"
@@ -23,6 +25,10 @@ RUNS_INGEST = "/api/sdk/runs/ingest"
23
25
  RUNS_START = "/api/sdk/runs/start"
24
26
  RUNS_BASE = "/api/sdk/runs" # /runs/{run_id}/end, /spans, /feedback
25
27
  OTLP_TRACES = "/api/sdk/otel/v1/traces"
26
- # Datasets & experiments ingest
28
+ # Datasets & experiments
27
29
  DATASETS_BASE = "/api/sdk/datasets" # /datasets/{ref}/items
30
+ EXPERIMENTS = "/api/sdk/experiments"
28
31
  EXPERIMENTS_INGEST = "/api/sdk/experiments/ingest"
32
+ EXPERIMENTS_CREATE = "/api/sdk/experiments/create"
33
+ EXPERIMENTS_RUN = "/api/sdk/experiments/run"
34
+ EXPERIMENTS_COMPARE = "/api/sdk/experiments/compare"
trodo/api/http_client.py CHANGED
@@ -9,6 +9,7 @@ from typing import Any, Callable, Dict, Optional
9
9
  import requests
10
10
 
11
11
  from ..types import ApiResult, EventPayload
12
+ from . import endpoints
12
13
 
13
14
 
14
15
  class HttpClient:
@@ -143,6 +144,28 @@ class HttpClient:
143
144
  def ingest_experiment(self, body: Dict[str, Any]) -> ApiResult:
144
145
  return self._request("/api/sdk/experiments/ingest", body)
145
146
 
147
+ def create_experiment(self, body: Dict[str, Any]) -> ApiResult:
148
+ return self._request("/api/sdk/experiments/create", body)
149
+
150
+ def run_experiment(self, body: Dict[str, Any]) -> ApiResult:
151
+ return self._request("/api/sdk/experiments/run", body)
152
+
153
+ def run_experiment_by_id(self, experiment_id: str) -> ApiResult:
154
+ from urllib.parse import quote
155
+ return self._request(f"/api/sdk/experiments/{quote(str(experiment_id), safe='')}/run", {})
156
+
157
+ def get_experiment(self, experiment_id: str) -> ApiResult:
158
+ from urllib.parse import quote
159
+ return self._get(f"/api/sdk/experiments/{quote(str(experiment_id), safe='')}")
160
+
161
+ def compare_experiments(
162
+ self, a_id: str, b_id: str, strict: bool = False
163
+ ) -> ApiResult:
164
+ params: Dict[str, Any] = {"a": a_id, "b": b_id}
165
+ if strict:
166
+ params["strict"] = "true"
167
+ return self._get("/api/sdk/experiments/compare", params)
168
+
146
169
  def post_track(self, session_data: Dict[str, Any]) -> ApiResult:
147
170
  return self._request("/api/sdk/track", {"sessionData": session_data})
148
171
 
@@ -161,6 +184,17 @@ class HttpClient:
161
184
  def post_reset(self, payload: Dict[str, Any]) -> ApiResult:
162
185
  return self._request("/api/sdk/reset", payload)
163
186
 
187
+ def post_user_upsert(self, payload: Dict[str, Any]) -> ApiResult:
188
+ """Create-or-update a user and their traits.
189
+
190
+ Degrades gracefully against a backend that predates the endpoint:
191
+ ``_request`` never raises, so a 404 surfaces through ``on_error`` and
192
+ returns an empty result rather than breaking the caller's request
193
+ handler. Users on an older backend lose the trait write, not their
194
+ application.
195
+ """
196
+ return self._request(endpoints.USERS_UPSERT, payload)
197
+
164
198
  def post_people(self, path: str, payload: Dict[str, Any]) -> ApiResult:
165
199
  return self._request(path, payload)
166
200
 
trodo/client.py CHANGED
@@ -7,6 +7,7 @@ from typing import Any, Callable, Dict, List, Optional, Union
7
7
 
8
8
  from .api.http_client import HttpClient
9
9
  from .session.session_manager import SessionManager
10
+ from .util.lru import LruDict
10
11
  from .managers.group_manager import GroupProfile
11
12
  from .queue.event_queue import EventQueue
12
13
  from .queue.batch_flusher import BatchFlusher
@@ -59,6 +60,7 @@ class TrodoClient:
59
60
  auto_instrument: bool = True,
60
61
  disable_instrumentations: Optional[List[str]] = None,
61
62
  otel_mode: str = "trodo",
63
+ max_cached_users: int = 10_000,
62
64
  ) -> None:
63
65
  if not site_id:
64
66
  raise ValueError("trodo-python: site_id is required")
@@ -75,10 +77,11 @@ class TrodoClient:
75
77
  debug=debug,
76
78
  )
77
79
 
78
- self._session_manager = SessionManager()
80
+ self._session_manager = SessionManager(max_cached_users)
79
81
  self._prompts = None # lazily-built PromptManager
80
82
  self._datasets = None # lazily-built DatasetManager
81
83
  self._experiments = None # lazily-built ExperimentManager
84
+ self._users = None # lazily-built UserManager
82
85
 
83
86
  if batch_enabled:
84
87
  self._event_queue: Optional[EventQueue] = EventQueue(batch_size)
@@ -97,7 +100,10 @@ class TrodoClient:
97
100
  if auto_events:
98
101
  self._auto_event_manager.enable()
99
102
 
100
- self._user_context_cache: Dict[str, UserContext] = {}
103
+ # LRU-bounded (max_cached_users, default 10k). ``for_user`` caches a
104
+ # context per distinct id; unbounded that is a slow leak in a long-lived
105
+ # API server. An evicted user is simply rebuilt on next use.
106
+ self._user_context_cache: LruDict[str, UserContext] = LruDict(max_cached_users)
101
107
 
102
108
  self._span_processor = TrodoSpanProcessor(http_client=self._http)
103
109
  if auto_instrument:
@@ -137,12 +143,21 @@ class TrodoClient:
137
143
 
138
144
  @property
139
145
  def experiments(self):
140
- """Ingest experiment result batches for server-side grading."""
146
+ """Managed experiment runs + external ingest for server-side grading."""
141
147
  if self._experiments is None:
142
148
  from .managers.experiment_manager import ExperimentManager
143
149
  self._experiments = ExperimentManager(self._http)
144
150
  return self._experiments
145
151
 
152
+ @property
153
+ def users(self):
154
+ """Create-or-update a user and their traits — the stateless-backend
155
+ primitive. See :class:`~trodo.managers.user_manager.UserManager`."""
156
+ if self._users is None:
157
+ from .managers.user_manager import UserManager
158
+ self._users = UserManager(self._http)
159
+ return self._users
160
+
146
161
  # --------------------------------------------------------------------------
147
162
  # Primary pattern: for_user()
148
163
  # --------------------------------------------------------------------------
@@ -152,8 +167,9 @@ class TrodoClient:
152
167
  distinct_id: str,
153
168
  session_id: Optional[str] = None,
154
169
  ) -> UserContext:
155
- if distinct_id in self._user_context_cache:
156
- return self._user_context_cache[distinct_id]
170
+ cached = self._user_context_cache.get(distinct_id)
171
+ if cached is not None:
172
+ return cached
157
173
 
158
174
  ctx = UserContext(
159
175
  distinct_id=distinct_id,
@@ -165,7 +181,7 @@ class TrodoClient:
165
181
  auto_event_manager=self._auto_event_manager,
166
182
  session_id=session_id,
167
183
  )
168
- self._user_context_cache[distinct_id] = ctx
184
+ self._user_context_cache.set(distinct_id, ctx)
169
185
  return ctx
170
186
 
171
187
  # --------------------------------------------------------------------------
@@ -182,8 +198,9 @@ class TrodoClient:
182
198
  self.for_user(distinct_id).track(event_name, properties, category)
183
199
 
184
200
  def identify(self, identify_id: str, session_id: Optional[str] = None) -> "UserContext":
185
- if identify_id in self._user_context_cache:
186
- return self._user_context_cache[identify_id]
201
+ cached = self._user_context_cache.get(identify_id)
202
+ if cached is not None:
203
+ return cached
187
204
  ctx = self.for_user(identify_id, session_id)
188
205
  ctx.identify(identify_id)
189
206
  return ctx
@@ -195,6 +212,26 @@ class TrodoClient:
195
212
  self._user_context_cache.pop(distinct_id, None)
196
213
  return self.for_user(distinct_id).reset()
197
214
 
215
+ def upsert_user(
216
+ self,
217
+ distinct_id: str,
218
+ *,
219
+ properties: Optional[Dict[str, Any]] = None,
220
+ set_once: Optional[Dict[str, Any]] = None,
221
+ fixed_properties: Optional[Dict[str, Any]] = None,
222
+ ) -> ApiResult:
223
+ """Create-or-update a user and their traits in one idempotent call.
224
+
225
+ Flat alias for ``client.users.upsert(...)``, matching the ``people_set``
226
+ convention.
227
+ """
228
+ return self.users.upsert(
229
+ distinct_id,
230
+ properties=properties,
231
+ set_once=set_once,
232
+ fixed_properties=fixed_properties,
233
+ )
234
+
198
235
  # People (direct)
199
236
  def people_set(self, distinct_id: str, properties: Dict[str, Any]) -> ApiResult:
200
237
  return self.for_user(distinct_id).people.set(properties)
@@ -289,11 +326,19 @@ class TrodoClient:
289
326
  conversation_id: Optional[str] = None,
290
327
  parent_run_id: Optional[str] = None,
291
328
  metadata: Optional[Dict[str, Any]] = None,
329
+ user: Optional[Dict[str, Any]] = None,
292
330
  ) -> wrap_agent_ctx:
293
331
  """Context manager that captures the wrapped block as an agent run.
294
332
 
295
333
  Every OTel-instrumented call (Anthropic, OpenAI, LangChain…) made
296
334
  inside the ``with`` is auto-captured as a nested span.
335
+
336
+ ``user`` optionally carries profile traits — ``{"properties": ...,
337
+ "set_once": ..., "fixed_properties": ...}`` — so a stateless request
338
+ handler can attribute the run AND enrich the profile in one round trip
339
+ instead of a separate ``upsert_user`` call it has to sequence
340
+ correctly. Applied server-side and non-fatally: a failed trait write
341
+ never costs you the trace. Use ``upsert_user`` when you need the result.
297
342
  """
298
343
  return wrap_agent_ctx(
299
344
  processor=self._span_processor,
@@ -303,6 +348,7 @@ class TrodoClient:
303
348
  conversation_id=conversation_id,
304
349
  parent_run_id=parent_run_id,
305
350
  metadata=metadata,
351
+ user=user,
306
352
  )
307
353
 
308
354
  def agent(
@@ -352,10 +398,13 @@ class TrodoClient:
352
398
  parent_run_id: Optional[str] = None,
353
399
  metadata: Optional[Dict[str, Any]] = None,
354
400
  input: Any = None,
401
+ user: Optional[Dict[str, Any]] = None,
355
402
  ) -> str:
356
403
  """Open a Run record outside a context manager. Returns the run_id.
357
404
 
358
405
  Use ``end_run`` to finalise, ``join_run`` from any process to add spans.
406
+
407
+ ``user`` carries profile traits alongside the run — see ``wrap_agent``.
359
408
  """
360
409
  return start_run_fn(
361
410
  processor=self._span_processor,
@@ -366,6 +415,7 @@ class TrodoClient:
366
415
  parent_run_id=parent_run_id,
367
416
  metadata=metadata,
368
417
  input=input,
418
+ user=user,
369
419
  )
370
420
 
371
421
  def end_run(
@@ -1,26 +1,25 @@
1
- """Experiment ingestrecord a batch of model outputs against a dataset.
1
+ """Experiment managermanaged runs and external ingest.
2
2
 
3
- An experiment takes a dataset (the fixed inputs) and a set of *outputs* your
4
- model produced for each item, then grades them server-side using the judge
5
- and/or evaluators you name. You run the model yourself and hand Trodo the
6
- results; Trodo scores and stores them so runs are comparable over time.
3
+ Managed experiments (prompt + model over a dataset snapshot) use
4
+ ``create``, ``run``, ``get``, ``wait``, and ``compare``.
5
+ External agent outputs use ``ingest``.
7
6
 
8
- ``outputs`` are matched to dataset items by ``item_position`` (0-based),
9
- carrying the produced ``output`` and optional per-row context (``query``,
10
- ``context``), cost/latency telemetry, and a per-row ``expected_output`` override.
11
-
12
- Mirrors ``sdks/trodo-node-sdk/src/managers/ExperimentManager.ts`` in shape.
7
+ Mirrors ``sdks/trodo-node-sdk/src/managers/ExperimentManager.ts``.
13
8
  """
14
9
 
15
10
  from __future__ import annotations
16
11
 
17
- from typing import Any, Dict, List, Optional
12
+ import time
13
+ from typing import Any, Dict, List, Optional, Union
18
14
 
19
15
  __all__ = ["ExperimentManager"]
20
16
 
21
- # Optional per-output fields, in wire order. ``item_position`` + ``output`` are
22
- # always required.
17
+ _TERMINAL = frozenset({"completed", "failed"})
18
+
19
+ # Optional per-output fields for ingest
23
20
  _OUTPUT_OPTIONAL = (
21
+ "item_position",
22
+ "dataset_item_id",
24
23
  "expected_output",
25
24
  "query",
26
25
  "context",
@@ -32,22 +31,79 @@ _OUTPUT_OPTIONAL = (
32
31
  def _clean_output(output: Dict[str, Any]) -> Dict[str, Any]:
33
32
  if not isinstance(output, dict):
34
33
  raise ValueError("trodo: each experiment output must be a dict")
35
- if output.get("item_position") is None:
36
- raise ValueError("trodo: each output requires an 'item_position' (int)")
37
34
  if output.get("output") is None:
38
35
  raise ValueError("trodo: each output requires an 'output' (str)")
39
- out: Dict[str, Any] = {
40
- "item_position": output["item_position"],
41
- "output": output["output"],
42
- }
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"]}
43
41
  for key in _OUTPUT_OPTIONAL:
44
42
  if output.get(key) is not None:
45
43
  out[key] = output[key]
46
44
  return out
47
45
 
48
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
+
49
105
  class ExperimentManager:
50
- """Ingest experiment result batches for server-side grading."""
106
+ """Run and inspect Trodo experiments programmatically."""
51
107
 
52
108
  def __init__(self, http_client: Any) -> None:
53
109
  self._http = http_client
@@ -62,23 +118,7 @@ class ExperimentManager:
62
118
  evaluator_ids: Optional[List[str]] = None,
63
119
  dataset_version_no: Optional[int] = None,
64
120
  ) -> Dict[str, Any]:
65
- """Ingest a batch of *outputs* for *dataset* and return the experiment.
66
-
67
- *dataset* is a dataset name or UUID. *outputs* is a list of dicts shaped
68
- ``{"item_position": int, "output": str, "expected_output"?, "query"?,
69
- "context"?, "cost_usd"?, "latency_ms"?}``. ``None`` optional fields are
70
- dropped from the wire payload.
71
-
72
- Grading is configured server-side: pass ``judge`` (``{"credential_id",
73
- "provider", "model"}``) for an LLM judge and/or ``evaluator_ids`` for
74
- named evaluators. ``dataset_version_no`` pins the dataset version graded
75
- against.
76
-
77
- Returns the experiment dict from ``{"experiment": {...}}``.
78
-
79
- :raises ValueError: if *dataset* is empty, *outputs* is empty, or an
80
- output is missing ``item_position``/``output``.
81
- """
121
+ """Ingest externally-produced outputs and grade them (external task path)."""
82
122
  if not dataset:
83
123
  raise ValueError("trodo: ingest_experiment(dataset) requires a dataset")
84
124
  if not outputs:
@@ -99,7 +139,148 @@ class ExperimentManager:
99
139
  if dataset_version_no is not None:
100
140
  body["dataset_version_no"] = dataset_version_no
101
141
 
102
- res = self._http.ingest_experiment(body)
103
- if isinstance(res, dict) and "experiment" in res:
104
- return res["experiment"]
105
- return res
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")
@@ -0,0 +1,70 @@
1
+ """User upsert — create-or-update a user and their traits in one call.
2
+
3
+ Backend services are stateless: a request handler knows who the user is and a
4
+ few things about them for the duration of one request, and holds nothing between
5
+ requests. This is the call that expresses that.
6
+
7
+ trodo.upsert_user(
8
+ "user-42",
9
+ properties={"plan": "pro"},
10
+ set_once={"signup_date": "2026-01-04"},
11
+ fixed_properties={"last_location_country": "India"},
12
+ )
13
+
14
+ Every bag is optional. With none of them it still ensures the user exists —
15
+ useful at signup, before they have produced any agent runs. Safe to call on
16
+ every request: it is idempotent, and ``set_once`` keys are only written the
17
+ first time.
18
+
19
+ Mirrors ``sdks/trodo-node-sdk/src/managers/UserManager.ts``.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import Any, Dict, Optional
25
+
26
+ from ..types import ApiResult
27
+
28
+ __all__ = ["UserManager"]
29
+
30
+
31
+ class UserManager:
32
+ def __init__(self, http_client: object) -> None:
33
+ self._http = http_client
34
+
35
+ def upsert(
36
+ self,
37
+ distinct_id: str,
38
+ *,
39
+ properties: Optional[Dict[str, Any]] = None,
40
+ set_once: Optional[Dict[str, Any]] = None,
41
+ fixed_properties: Optional[Dict[str, Any]] = None,
42
+ ) -> ApiResult:
43
+ """Create or update ``distinct_id`` and apply the supplied traits.
44
+
45
+ :param distinct_id: The user's id — the same value passed as
46
+ ``distinct_id`` to ``wrap_agent``, and the same one the browser SDK
47
+ calls ``identify()`` with. Matching them is what merges a user's
48
+ agent runs and product events into one profile.
49
+ :param properties: Merged into the user's custom properties.
50
+ :param set_once: Written only where the key is absent.
51
+ :param fixed_properties: Allowlisted real columns —
52
+ ``first/last_location_country`` and ``first/last_location_city``.
53
+ Device, browser, OS, referrer and UTM are browser observations and
54
+ come back in ``skipped`` rather than being stored; a backend
55
+ supplying them is guessing.
56
+
57
+ :returns: ``{"success", "distinctId", "created", "applied", "skipped"}``.
58
+ A skipped fixed key is a normal outcome (browser-owned or
59
+ browser-only), not an error.
60
+ """
61
+ # Absent bags are omitted rather than sent as null: the server
62
+ # distinguishes "no bag" from "empty bag" by presence.
63
+ payload: Dict[str, Any] = {"distinctId": distinct_id}
64
+ if properties is not None:
65
+ payload["properties"] = properties
66
+ if set_once is not None:
67
+ payload["setOnce"] = set_once
68
+ if fixed_properties is not None:
69
+ payload["fixedProperties"] = fixed_properties
70
+ return self._http.post_user_upsert(payload) # type: ignore[attr-defined]
trodo/otel/processor.py CHANGED
@@ -36,6 +36,10 @@ class TrodoRun:
36
36
  # Free-form run-level attributes (e.g. {"trodo.prompts": [...]} — the set of
37
37
  # managed-prompt versions used across the run, for prompt traceability).
38
38
  attributes: Optional[Dict[str, Any]] = None
39
+ # Optional user traits applied alongside the run (see wrap_agent's `user`
40
+ # kwarg). Forwarded verbatim to the backend, which applies them
41
+ # non-fatally — this is not a run column.
42
+ user: Optional[Dict[str, Any]] = None
39
43
  # Aggregates summed from child spans at finalisation.
40
44
  total_tokens_in: Optional[int] = None
41
45
  total_tokens_out: Optional[int] = None
trodo/otel/wrap_agent.py CHANGED
@@ -212,6 +212,30 @@ def _aggregate(spans: list[TrodoSpan]) -> Dict[str, Any]:
212
212
  }
213
213
 
214
214
 
215
+ def _user_bag(user: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
216
+ """Normalise a user-traits bag for the wire, or None when it carries
217
+ nothing.
218
+
219
+ Accepts both snake_case (``set_once`` / ``fixed_properties``, natural in
220
+ Python) and the camelCase the wire uses, so callers can write either.
221
+ Omitting an empty bag keeps the payload honest — the backend distinguishes
222
+ "no traits" from "empty traits" by presence.
223
+ """
224
+ if not isinstance(user, dict):
225
+ return None
226
+ out: Dict[str, Any] = {}
227
+ props = user.get("properties")
228
+ once = user.get("set_once", user.get("setOnce"))
229
+ fixed = user.get("fixed_properties", user.get("fixedProperties"))
230
+ if isinstance(props, dict) and props:
231
+ out["properties"] = props
232
+ if isinstance(once, dict) and once:
233
+ out["setOnce"] = once
234
+ if isinstance(fixed, dict) and fixed:
235
+ out["fixedProperties"] = fixed
236
+ return out or None
237
+
238
+
215
239
  def _mint_anon_distinct_id() -> str:
216
240
  """Mint a server-side anonymous distinct_id for an agent run.
217
241
 
@@ -420,6 +444,7 @@ def start_run(
420
444
  parent_run_id: Optional[str] = None,
421
445
  metadata: Optional[Dict[str, Any]] = None,
422
446
  input: Any = None,
447
+ user: Optional[Dict[str, Any]] = None,
423
448
  ) -> str:
424
449
  """Open a Run record without holding a context manager.
425
450
 
@@ -448,6 +473,7 @@ def start_run(
448
473
  input=_prepare_value(input),
449
474
  started_at=_now_iso(),
450
475
  metadata=metadata,
476
+ user=_user_bag(user),
451
477
  )
452
478
  processor.mark_joined(rid)
453
479
  processor.start_run(run)
@@ -503,6 +529,7 @@ class wrap_agent:
503
529
  conversation_id: Optional[str] = None,
504
530
  parent_run_id: Optional[str] = None,
505
531
  metadata: Optional[Dict[str, Any]] = None,
532
+ user: Optional[Dict[str, Any]] = None,
506
533
  ) -> None:
507
534
  self._processor = processor
508
535
  self._team_site_id = team_site_id
@@ -515,6 +542,7 @@ class wrap_agent:
515
542
  self._conversation_id = conversation_id
516
543
  self._parent_run_id = parent_run_id
517
544
  self._metadata = metadata
545
+ self._user = _user_bag(user)
518
546
  self._ctx_mgr: Optional[run_with_context] = None
519
547
  self._ctx: Optional[ActiveSpanContext] = None
520
548
  self._started_ms: float = 0.0
@@ -600,6 +628,9 @@ class wrap_agent:
600
628
  error_summary=error_summary,
601
629
  error_type=error_type,
602
630
  metadata={**(self._metadata or {}), **self.handle.metadata} or None,
631
+ # Traits travel on the failure path too — a run that threw is
632
+ # exactly when you still want to know who the user was.
633
+ user=self._user,
603
634
  attributes=(
604
635
  {"trodo.prompts": self._ctx.prompt_state["all"]}
605
636
  if self._ctx is not None
@@ -6,12 +6,17 @@ import threading
6
6
  from typing import Dict, Optional
7
7
 
8
8
  from ..types import ServerSession
9
+ from ..util.lru import DEFAULT_MAX_SIZE, LruDict
9
10
  from .server_session import build_session_payload, create_server_session
10
11
 
11
12
 
12
13
  class SessionManager:
13
- def __init__(self) -> None:
14
- self._sessions: Dict[str, ServerSession] = {}
14
+ def __init__(self, max_cached_users: int = DEFAULT_MAX_SIZE) -> None:
15
+ # LRU-bounded: one entry per distinct id, and a long-lived backend can
16
+ # see unbounded distinct ids. Evicted users are rebuilt on next use, and
17
+ # the server session id is deterministic (``server:{distinct_id}``), so a
18
+ # rebuild resolves to the same session rather than creating a duplicate.
19
+ self._sessions: LruDict[str, ServerSession] = LruDict(max_cached_users)
15
20
  self._lock = threading.Lock()
16
21
  self._confirmation_locks: Dict[str, threading.Event] = {}
17
22
  self._confirmation_started: Dict[str, bool] = {}
@@ -23,10 +28,11 @@ class SessionManager:
23
28
  session_id: Optional[str] = None,
24
29
  ) -> ServerSession:
25
30
  with self._lock:
26
- if distinct_id in self._sessions:
27
- return self._sessions[distinct_id]
31
+ existing = self._sessions.get(distinct_id)
32
+ if existing is not None:
33
+ return existing
28
34
  session = create_server_session(site_id, distinct_id, session_id)
29
- self._sessions[distinct_id] = session
35
+ self._sessions.set(distinct_id, session)
30
36
  return session
31
37
 
32
38
  def ensure_confirmed(self, session: ServerSession, http_client: object) -> None:
trodo/user_context.py CHANGED
@@ -45,13 +45,16 @@ class UserContext:
45
45
  self._batch_flusher = batch_flusher
46
46
  self._auto_event_manager = auto_event_manager
47
47
  self._session_id_override = session_id
48
+ # Created on first use, not here.
49
+ #
50
+ # The session used to be built eagerly, which meant
51
+ # ``for_user(id).people.set(...)`` constructed a session object it never
52
+ # sent or read — pure waste on the stateless-backend path, where setting
53
+ # a property on a user you will not otherwise touch is a common call.
54
+ # Sessions are now created by the first operation that needs one
55
+ # (track / identify / wallet_address / capture_error).
48
56
  self._session: Optional["ServerSession"] = None
49
57
 
50
- # Eagerly initialise session
51
- self._session = self._session_manager.get_or_create(
52
- distinct_id, site_id, session_id
53
- )
54
-
55
58
  self.people = PeopleManager(
56
59
  http_client, site_id, lambda: self._get_distinct_id()
57
60
  )
trodo/util/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Internal utilities. Not part of the public API."""
trodo/util/lru.py ADDED
@@ -0,0 +1,71 @@
1
+ """A dict with a size ceiling that evicts least-recently-used entries.
2
+
3
+ The SDK caches one context and one session per distinct id so repeated calls for
4
+ the same user reuse them. Unbounded, that is a slow leak in exactly the
5
+ deployment this SDK targets: a long-lived API server whose user cardinality
6
+ grows without limit. A worker touching ten users never noticed; a service
7
+ touching a million did.
8
+
9
+ Eviction is safe because every cached value is reconstructible — an evicted user
10
+ is rebuilt on next use, and server session ids are deterministic
11
+ (``server:{distinct_id}``), so rebuilding produces the same session rather than a
12
+ duplicate. The only cost of a miss is re-creating a small object.
13
+
14
+ Backed by :class:`collections.OrderedDict`, whose ``move_to_end`` gives the
15
+ recency ordering directly. Guarded by a lock because backend SDK users routinely
16
+ call from a thread pool.
17
+
18
+ Mirrors ``sdks/trodo-node-sdk/src/util/LruMap.ts``.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import threading
24
+ from collections import OrderedDict
25
+ from typing import Any, Generic, Optional, TypeVar
26
+
27
+ __all__ = ["LruDict"]
28
+
29
+ K = TypeVar("K")
30
+ V = TypeVar("V")
31
+
32
+ DEFAULT_MAX_SIZE = 10_000
33
+
34
+
35
+ class LruDict(Generic[K, V]):
36
+ def __init__(self, max_size: int = DEFAULT_MAX_SIZE) -> None:
37
+ # A non-positive cap would evict on every set and defeat the cache;
38
+ # treat it as "no caching is not a supported mode".
39
+ self._max_size = max_size if isinstance(max_size, int) and max_size > 0 else DEFAULT_MAX_SIZE
40
+ self._data: "OrderedDict[K, V]" = OrderedDict()
41
+ self._lock = threading.Lock()
42
+
43
+ def get(self, key: K) -> Optional[V]:
44
+ with self._lock:
45
+ if key not in self._data:
46
+ return None
47
+ self._data.move_to_end(key)
48
+ return self._data[key]
49
+
50
+ def set(self, key: K, value: V) -> None:
51
+ with self._lock:
52
+ self._data[key] = value
53
+ self._data.move_to_end(key)
54
+ while len(self._data) > self._max_size:
55
+ self._data.popitem(last=False)
56
+
57
+ def pop(self, key: K, default: Any = None) -> Any:
58
+ with self._lock:
59
+ return self._data.pop(key, default)
60
+
61
+ def clear(self) -> None:
62
+ with self._lock:
63
+ self._data.clear()
64
+
65
+ def __contains__(self, key: object) -> bool:
66
+ with self._lock:
67
+ return key in self._data
68
+
69
+ def __len__(self) -> int:
70
+ with self._lock:
71
+ return len(self._data)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: trodo-python
3
- Version: 2.16.0
3
+ Version: 2.18.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
@@ -123,7 +123,7 @@ Creates the session and fires `POST /api/sdk/identify`. Use to link a `distinct_
123
123
 
124
124
  ```python
125
125
  user = trodo.identify('user@example.com', session_id=request.cookies.get('trodo_session'))
126
- # distinct_id is now id_user@example.com — merges with browser events
126
+ # distinct_id is now user@example.com — merges with browser events
127
127
  user.track('login')
128
128
  ```
129
129
 
@@ -171,6 +171,59 @@ trodo.people_set('user-123', {'plan': 'pro'})
171
171
  trodo.set_group('user-123', 'company', 'acme')
172
172
  ```
173
173
 
174
+ ### `upsert_user` — identity and traits in one call
175
+
176
+ Create-or-update a user and their traits idempotently. This is the call a
177
+ stateless request handler makes; safe to run on every request.
178
+
179
+ ```python
180
+ result = trodo.upsert_user(
181
+ 'user-42',
182
+ properties={'plan': 'pro', 'company': 'Acme'}, # merged
183
+ set_once={'signup_date': '2026-01-04'}, # fill-if-absent
184
+ fixed_properties={'last_location_country': 'India'},
185
+ )
186
+
187
+ result['created'] # True if this call created the user
188
+ result['applied']['properties'] # ['plan', 'company']
189
+ result['skipped']['fixedProperties']
190
+ ```
191
+
192
+ All three bags are optional. With none of them it still ensures the user
193
+ exists — what you want at signup, before they've produced any runs.
194
+
195
+ **Fixed vs custom properties.** `properties` / `set_once` are free-form and live
196
+ on the profile as custom properties. `fixed_properties` writes the real columns
197
+ the dashboard filters on, and only a backend-knowable subset is accepted:
198
+
199
+ | Settable from a backend | Browser-only |
200
+ |---|---|
201
+ | `first_location_country`, `last_location_country` | `device_type`, `browser_name`, `os` |
202
+ | `first_location_city`, `last_location_city` | `first_referrer`, `last_referrer`, `utm_*` |
203
+
204
+ A browser-only key isn't an error — it comes back in `skipped` with
205
+ `reason: 'BROWSER_ONLY_PROPERTY'` and the rest of the batch still applies. The
206
+ reasoning: a backend supplying a browser name is guessing, and a wrong guess
207
+ silently corrupts attribution reporting.
208
+
209
+ Where both a browser and a backend write the same column, **the browser wins** —
210
+ it observed the value, the server supplied one — and the server write is
211
+ reported as `BROWSER_OWNED_PROPERTY`.
212
+
213
+ ### Attributing and enriching in one call
214
+
215
+ `wrap_agent` and `start_run` take an optional `user=` bag, so a request handler
216
+ doesn't need a separate `upsert_user` call it has to sequence correctly:
217
+
218
+ ```python
219
+ with trodo.wrap_agent('support-agent', distinct_id=user_id,
220
+ user={'properties': {'plan': 'pro'}}) as run:
221
+ ...
222
+ ```
223
+
224
+ Applied server-side and non-fatally — a failed trait write never costs you the
225
+ trace. Use `upsert_user` directly when you need to know what landed.
226
+
174
227
  ---
175
228
 
176
229
  ## AI Agent Tracing (recommended)
@@ -617,10 +670,10 @@ Call `identify()` with the **same value** on the browser and server to merge all
617
670
 
618
671
  ```python
619
672
  # Python
620
- user.identify('user@example.com') # → id_user@example.com
673
+ user.identify('user@example.com') # → user@example.com
621
674
 
622
675
  # Browser (same value)
623
- # Trodo.identify('user@example.com') → id_user@example.com
676
+ # Trodo.identify('user@example.com') → user@example.com
624
677
  # Events from both sides now appear together in the dashboard
625
678
  ```
626
679
 
@@ -1,28 +1,29 @@
1
- trodo/__init__.py,sha256=V2vh8SxMkgOaBdowPlHzx_2hpX4T1lUlS2JmE6usz3M,23362
2
- trodo/client.py,sha256=syz4zzWokdBaFNnO5QhYnUKVNHf8KaunofVFc1ODohI,20187
1
+ trodo/__init__.py,sha256=Z24IqU5MsOX2VP6xwWcgpy5UiJb3DniXFmQPiiYlxtQ,26243
2
+ trodo/client.py,sha256=5Uxt0tTLDjuMlXIqqdTbg4uiuISY5u7MTK4F1wahdfc,22281
3
3
  trodo/types.py,sha256=eySgUvCXROG2TxtxgiU0MNr5iH0DEcduK8bmYtTKG44,3138
4
- trodo/user_context.py,sha256=9la6azzwEanVmdP4ps_xMoufbeWVeIGU-M8ychmgajg,7859
4
+ trodo/user_context.py,sha256=uHCI2WYoOI3cNwdIUEuZdX4VYSu14pzm348ksn6hn_c,8195
5
5
  trodo/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
6
  trodo/api/async_client.py,sha256=rZN4aJ2QiKyrHBK260bApCUB9JaMWU6BQtzoSJZh7xk,3408
7
- trodo/api/endpoints.py,sha256=JVTn5l1U-7U2ibQoR9M_AjRgK6GMRgSx_DkV4opWouo,1171
8
- trodo/api/http_client.py,sha256=rWeRLqVXQ-iXgAIzg6701WtK5eSlPZTTJgKZFerLZhs,8085
7
+ trodo/api/endpoints.py,sha256=H0Da456mf2IUNZH5bsln69IIUoUhdF4bncPo-WDLZ9I,1433
8
+ trodo/api/http_client.py,sha256=RRsXVDqTp3H8OrOqM-7tsE3_WKHYCuRkf9icvyowVQE,9577
9
9
  trodo/auto/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
10
  trodo/auto/auto_event_manager.py,sha256=cztuRsRkNoJE5R4NfSfTrTJTGl4jx2Yb-Ncy0aVAPo8,4247
11
11
  trodo/managers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
12
  trodo/managers/dataset_manager.py,sha256=gx0S8ujG3cbw4IskBNMdceBjDbcEh_nebwKSzEvm7hE,2827
13
- trodo/managers/experiment_manager.py,sha256=9GO1JUuV7FykaPfZXI8NVvEBDS2CZuyw9I3ywCrZBTI,3916
13
+ trodo/managers/experiment_manager.py,sha256=V-vemLtenfSI5VIcP0HPiXzEy8AyTrRrbJVCA9Z6wjo,10579
14
14
  trodo/managers/group_manager.py,sha256=ki3Se3qEoSZfREX63oeDeBmEfZF-ISHLE8azEtLg0tM,3542
15
15
  trodo/managers/people_manager.py,sha256=mMVnx40Mlifx6NGgChvohC9ViK6dQu2mkXNHbV8pK1E,2882
16
16
  trodo/managers/prompt_manager.py,sha256=jFHkdvDSxvvb9c53EQvBQygdDJ7X-ylNWEY_ag_SeC0,11227
17
+ trodo/managers/user_manager.py,sha256=faJYX3CHrD7ulYiShV7FhThSMX9aJ3kdOgU4Qtdy5FM,2844
17
18
  trodo/otel/__init__.py,sha256=yiRFXWUU45bAM2CV37XeO7zf1hmnmjufdP4XO50yEyE,624
18
19
  trodo/otel/auto_instrument.py,sha256=Iae9A9lvh2PImE6gqnyEMXeRPRpjTxZcxx1zW2KDLac,21467
19
20
  trodo/otel/context.py,sha256=Jd0aTc0Q-1dM5kXXinhZD8YtnpdKgvneNiODyboVGKY,1418
20
21
  trodo/otel/helpers.py,sha256=XOMWcgZHaq5SQbkFxDaXPE4CDFjn01xjJmJ1vIxvwpw,20730
21
- trodo/otel/processor.py,sha256=ffhrzPDUBwEzeUBa9k5oKnl6vw7NV0urAoco2Z2aiVY,7703
22
+ trodo/otel/processor.py,sha256=LKlXxP3BeQ7DP8SzYAgXZZOeJ6-6b7e43i19gCUC_0Y,7939
22
23
  trodo/otel/prompt_trace.py,sha256=BIrdLOpsR1_HoaCmWb_706GwZSs-UG3p76fjvX3CX3w,3391
23
24
  trodo/otel/register.py,sha256=bV_ePTfUvPugig2GZnylhzxi2QfoPWs96A9mGLPMrSQ,9387
24
25
  trodo/otel/transport.py,sha256=hzZz8gwSMGJ8CxdijmLn1Ljt18owr9XTWy13DLbwYbw,2441
25
- trodo/otel/wrap_agent.py,sha256=9lsrt6o25dZ8RGdHXERRF-PbZ8RqIoX1dw_cXF_dBdQ,39724
26
+ trodo/otel/wrap_agent.py,sha256=cJjrzlZNW2g6q_coLN7UR4uxn65h4u3-LNzMfE4dLVw,40999
26
27
  trodo/prompts/__init__.py,sha256=yunNc8WkTSfEEkRR-NZWXAt_ZB3Ee2INRh-4RE7uHj4,806
27
28
  trodo/prompts/compile.py,sha256=sEMl8EWdK0G9uOKGcgc39ujnA5RLCUtbk26TYVqK68Q,6345
28
29
  trodo/prompts/template.py,sha256=obQiivPCR6LEdz7q1cby7uL25tYHYui4aoEaiUNqWfs,9357
@@ -32,8 +33,10 @@ trodo/queue/batch_flusher.py,sha256=4Lg6T3Urwi9U0Q4FpFGPmjDYKg4ZliCTR-ND8BJvWaY,
32
33
  trodo/queue/event_queue.py,sha256=EVFZrhlq_kwC3jJ2GK0wMhHISf9UzLCZNDnT_aZ2I2A,872
33
34
  trodo/session/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
34
35
  trodo/session/server_session.py,sha256=McsudEiq33XDq3nqxgzBcUvIjQxCMscwEuAPnYXrTjs,2136
35
- trodo/session/session_manager.py,sha256=JrgH1VeicmtlxPR4dXEuJbxhi23OelkgwW3-9Slv80o,2525
36
- trodo_python-2.16.0.dist-info/METADATA,sha256=i4J0WgohVDAcQi8OCVa_x6WpDhH9mWeYCEgX8BQ2lLs,23115
37
- trodo_python-2.16.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
38
- trodo_python-2.16.0.dist-info/top_level.txt,sha256=VCQu1CJWFmNsqTs1YxMcw4Mq35Tc3z3uI9RwHEXAayQ,6
39
- trodo_python-2.16.0.dist-info/RECORD,,
36
+ trodo/session/session_manager.py,sha256=7ht5LeeZX1HLLfPeNV_a8NkXGbHl3up0CRtCGr3EzjQ,2995
37
+ trodo/util/__init__.py,sha256=Z9c4rPPdKg06Kk3byKheDqksSgR4WNvS475oQ5sNljc,54
38
+ trodo/util/lru.py,sha256=QIsM7s6J_E9ZjiXatSyReZIEeGypTAcgrPWWy8YsRa4,2465
39
+ trodo_python-2.18.0.dist-info/METADATA,sha256=Pa8V9NdIZEs6q0o4dyj4WIOBKBmd52xEt-NRQ_LgfUo,25308
40
+ trodo_python-2.18.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
41
+ trodo_python-2.18.0.dist-info/top_level.txt,sha256=VCQu1CJWFmNsqTs1YxMcw4Mq35Tc3z3uI9RwHEXAayQ,6
42
+ trodo_python-2.18.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (83.0.0)
2
+ Generator: setuptools (84.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5