Skip to content

Commit 6c2d563

Browse files
feat: add new event methods and update generation metadata fields
1 parent 9e11e5e commit 6c2d563

5 files changed

Lines changed: 169 additions & 5 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "litmus-python-sdk"
3-
version = "0.4.0"
3+
version = "0.5.0"
44
description = "Litmus Python SDK - implicit evals for AI products"
55
readme = "README.md"
66
requires-python = ">=3.12"

src/litmus/client.py

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
"$switch_model",
5454
"$retry_context",
5555
"$post_accept_edit",
56+
"$sessionend",
5657
"$blur",
5758
"$return",
5859
"$scroll_regression",
@@ -106,6 +107,32 @@ def event(self, event_type: EventType, **metadata: object) -> None:
106107
metadata=merged if merged else None,
107108
)
108109

110+
def edit(self, before: str, after: str, **metadata: object) -> None:
111+
"""Record that the user modified the output before using it.
112+
113+
Send the raw before/after text. The backend computes edit distance,
114+
diff classification, and derived metrics.
115+
"""
116+
self.event("$edit", before=before, after=after, **metadata)
117+
118+
def accept(self, **metadata: object) -> None:
119+
"""User used the output as-is."""
120+
self.event("$accept", **metadata)
121+
122+
def copy(self, **metadata: object) -> None:
123+
"""User copied the output."""
124+
self.event("$copy", **metadata)
125+
126+
def regenerate(self, **metadata: object) -> None:
127+
"""User requested a new output. Fire BEFORE creating the next generation."""
128+
self.event("$regenerate", **metadata)
129+
130+
def share(self, channel: str | None = None, **metadata: object) -> None:
131+
"""User shared the output."""
132+
if channel:
133+
metadata["channel"] = channel
134+
self.event("$share", **metadata)
135+
109136

110137
class Feature:
111138
"""Scoped handle for an AI feature. Carries defaults so you don't
@@ -271,6 +298,14 @@ def track(
271298
generation_id: str | None = None,
272299
metadata: dict | None = None,
273300
timestamp: datetime | None = None,
301+
model: str | None = None,
302+
provider: str | None = None,
303+
input_tokens: int | None = None,
304+
output_tokens: int | None = None,
305+
total_tokens: int | None = None,
306+
duration_ms: int | None = None,
307+
ttft_ms: int | None = None,
308+
cost: float | None = None,
274309
) -> str | None:
275310
"""Enqueue a single event. Returns the event UUID or None if dropped."""
276311
if self.disabled:
@@ -294,6 +329,23 @@ def track(
294329
if generation_id:
295330
msg["generation_id"] = generation_id
296331

332+
if model:
333+
msg["model"] = model
334+
if provider:
335+
msg["provider"] = provider
336+
if input_tokens is not None:
337+
msg["input_tokens"] = input_tokens
338+
if output_tokens is not None:
339+
msg["output_tokens"] = output_tokens
340+
if total_tokens is not None:
341+
msg["total_tokens"] = total_tokens
342+
if duration_ms is not None:
343+
msg["duration_ms"] = duration_ms
344+
if ttft_ms is not None:
345+
msg["ttft_ms"] = ttft_ms
346+
if cost is not None:
347+
msg["cost"] = cost
348+
297349
props = {"$lib": "litmus-python", "$lib_version": VERSION}
298350
if metadata:
299351
props.update(metadata)
@@ -328,10 +380,25 @@ def generation(
328380
prompt_id: str | None = None,
329381
prompt_version: str | None = None,
330382
model: str | None = None,
383+
provider: str | None = None,
384+
input_tokens: int | None = None,
385+
output_tokens: int | None = None,
386+
total_tokens: int | None = None,
387+
duration_ms: int | None = None,
388+
ttft_ms: int | None = None,
389+
cost: float | None = None,
331390
metadata: dict | None = None,
391+
generation_id: str | None = None,
332392
) -> Generation:
333-
"""Create a generation and return a handle for recording signals."""
334-
generation_id = str(uuid4())
393+
"""Create a generation and return a handle for recording signals.
394+
395+
Pass ``generation_id`` when the caller has already minted an id
396+
(e.g. to correlate with an OTel/OpenRouter trace broadcast). When
397+
omitted, a fresh UUID4 is used. The id is emitted on the
398+
``$generation`` event, so downstream joins work either way.
399+
"""
400+
if generation_id is None:
401+
generation_id = str(uuid4())
335402
defaults = {
336403
"user_id": user_id,
337404
"prompt_id": prompt_id,
@@ -348,6 +415,14 @@ def generation(
348415
prompt_id=prompt_id,
349416
prompt_version=prompt_version,
350417
metadata=metadata,
418+
model=model,
419+
provider=provider,
420+
input_tokens=input_tokens,
421+
output_tokens=output_tokens,
422+
total_tokens=total_tokens,
423+
duration_ms=duration_ms,
424+
ttft_ms=ttft_ms,
425+
cost=cost,
351426
)
352427

353428
return Generation(self, session_id, generation_id, defaults)

src/litmus/consumer.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ def __init__(
3636
api_key: str,
3737
host: str | None = None,
3838
on_error: Callable[[Exception, list[dict]], None] | None = None,
39-
flush_at: int = 100,
39+
flush_at: int = 10,
4040
flush_interval: float = 0.5,
4141
use_gzip: bool = False,
4242
retries: int = 10,

tests/test_client.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,95 @@ def test_feature_track(self, mock_transport: CallLog) -> None:
260260
assert events[0]["user_id"] == "u1"
261261

262262

263+
class TestGenerationIdOverride:
264+
"""generation() accepts an externally-minted generation_id so callers
265+
can correlate the $generation event with ids they already emit elsewhere
266+
(e.g. OTel span metadata broadcast by OpenRouter)."""
267+
268+
def test_uses_provided_generation_id(self, mock_transport: CallLog) -> None:
269+
client = LitmusClient(api_key="ltm_pk_test_abc", sync_mode=True)
270+
gen = client.generation("s1", prompt_id="chat", generation_id="external-uuid")
271+
assert gen.id == "external-uuid"
272+
273+
gen.event("$accept")
274+
events = mock_transport.events()
275+
assert all(e["generation_id"] == "external-uuid" for e in events)
276+
277+
def test_default_generates_uuid_when_absent(self, mock_transport: CallLog) -> None:
278+
client = LitmusClient(api_key="ltm_pk_test_abc", sync_mode=True)
279+
gen = client.generation("s1")
280+
# UUID4 string: 36 chars, 4 hyphens
281+
assert len(gen.id) == 36
282+
assert gen.id.count("-") == 4
283+
284+
285+
class TestGenerationTopLevelFields:
286+
"""generation() forwards model, provider, tokens, cost, latency as
287+
TOP-LEVEL wire fields on the $generation event. The ingest server
288+
stores these in dedicated columns (model TEXT, provider TEXT, etc.)
289+
so dashboards can query them without JSONB lookups.
290+
291+
Regression guard for the v0.4.0 bug where these were accepted as
292+
kwargs but silently dropped before send."""
293+
294+
def test_model_is_top_level(self, mock_transport: CallLog) -> None:
295+
client = LitmusClient(api_key="ltm_pk_test_abc", sync_mode=True)
296+
client.generation("s1", prompt_id="chat", model="claude-sonnet-4-20250514")
297+
298+
event = next(e for e in mock_transport.events() if e["type"] == "$generation")
299+
assert event["model"] == "claude-sonnet-4-20250514"
300+
# Must NOT be in metadata — that was the old path we're moving off.
301+
assert event["metadata"].get("model") is None
302+
303+
def test_provider_and_usage_are_top_level(self, mock_transport: CallLog) -> None:
304+
client = LitmusClient(api_key="ltm_pk_test_abc", sync_mode=True)
305+
client.generation(
306+
"s1",
307+
prompt_id="chat",
308+
model="gpt-4o",
309+
provider="openai",
310+
input_tokens=120,
311+
output_tokens=340,
312+
total_tokens=460,
313+
duration_ms=1850,
314+
ttft_ms=240,
315+
cost=0.0042,
316+
)
317+
318+
event = next(e for e in mock_transport.events() if e["type"] == "$generation")
319+
assert event["model"] == "gpt-4o"
320+
assert event["provider"] == "openai"
321+
assert event["input_tokens"] == 120
322+
assert event["output_tokens"] == 340
323+
assert event["total_tokens"] == 460
324+
assert event["duration_ms"] == 1850
325+
assert event["ttft_ms"] == 240
326+
assert event["cost"] == 0.0042
327+
328+
def test_optional_fields_omitted_when_none(self, mock_transport: CallLog) -> None:
329+
"""None-valued fields are dropped from the wire payload entirely —
330+
the ingest server treats missing and null the same, but we keep
331+
payloads lean."""
332+
client = LitmusClient(api_key="ltm_pk_test_abc", sync_mode=True)
333+
client.generation("s1", prompt_id="chat")
334+
335+
event = next(e for e in mock_transport.events() if e["type"] == "$generation")
336+
for key in ("model", "provider", "input_tokens", "output_tokens",
337+
"total_tokens", "duration_ms", "ttft_ms", "cost"):
338+
assert key not in event, f"{key} should be omitted when None"
339+
340+
def test_feature_forwards_model_top_level(self, mock_transport: CallLog) -> None:
341+
"""Feature-scoped defaults also end up as top-level wire fields so
342+
dashboards using feature() get the same model column as callers
343+
using generation() directly."""
344+
client = LitmusClient(api_key="ltm_pk_test_abc", sync_mode=True)
345+
feat = client.feature("summarizer", model="claude-haiku-4")
346+
feat.generation("s1")
347+
348+
event = next(e for e in mock_transport.events() if e["type"] == "$generation")
349+
assert event["model"] == "claude-haiku-4"
350+
351+
263352
class TestAttach:
264353
"""attach() returns a Generation handle without emitting $generation."""
265354

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)