fluxmeter 1.0.0__tar.gz

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.
@@ -0,0 +1,7 @@
1
+ dist/
2
+ *.egg-info/
3
+ .eggs/
4
+ build/
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ __pycache__/
@@ -0,0 +1,133 @@
1
+ Metadata-Version: 2.4
2
+ Name: fluxmeter
3
+ Version: 1.0.0
4
+ Summary: Python SDK for FluxMeter — streaming metering for AI token billing
5
+ Project-URL: Homepage, https://github.com/10kshuaizhang/fluxmeter
6
+ Project-URL: Repository, https://github.com/10kshuaizhang/fluxmeter
7
+ Author-email: FluxMeter <hello@fluxmeter.dev>
8
+ License-Expression: Apache-2.0
9
+ Keywords: ai,anthropic,billing,llm,metering,openai,streaming,tokens
10
+ Classifier: Development Status :: 5 - Production/Stable
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: Apache Software License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Software Development :: Libraries
15
+ Requires-Python: >=3.9
16
+ Requires-Dist: confluent-kafka>=2.3.0
17
+ Provides-Extra: anthropic
18
+ Requires-Dist: anthropic>=0.20; extra == 'anthropic'
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest; extra == 'dev'
21
+ Requires-Dist: pytest-asyncio; extra == 'dev'
22
+ Requires-Dist: ruff; extra == 'dev'
23
+ Provides-Extra: openai
24
+ Requires-Dist: openai>=1.0; extra == 'openai'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # FluxMeter Python SDK
28
+
29
+ Send AI token usage events to FluxMeter for real-time aggregation and billing.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pip install fluxmeter
35
+ ```
36
+
37
+ ## Quick Start (3 lines)
38
+
39
+ ```python
40
+ from fluxmeter import FluxMeter
41
+
42
+ meter = FluxMeter(kafka_brokers="localhost:9094")
43
+ meter.track("cust_123", "gpt-4o", input_tokens=500, output_tokens=150)
44
+ ```
45
+
46
+ ## OpenAI Integration
47
+
48
+ ```python
49
+ import time
50
+ from openai import OpenAI
51
+ from fluxmeter import FluxMeter
52
+
53
+ client = OpenAI()
54
+ meter = FluxMeter(kafka_brokers="localhost:9094", environment="production")
55
+
56
+ start = time.time()
57
+ response = client.chat.completions.create(
58
+ model="gpt-4o",
59
+ messages=[{"role": "user", "content": "Hello!"}],
60
+ )
61
+ latency = int((time.time() - start) * 1000)
62
+
63
+ # One line to meter the usage
64
+ meter.track_openai("cust_123", response, latency_ms=latency)
65
+ ```
66
+
67
+ ## Anthropic Integration
68
+
69
+ ```python
70
+ import anthropic
71
+ from fluxmeter import FluxMeter
72
+
73
+ client = anthropic.Anthropic()
74
+ meter = FluxMeter(kafka_brokers="localhost:9094")
75
+
76
+ response = client.messages.create(
77
+ model="claude-sonnet-4-20250514",
78
+ max_tokens=1024,
79
+ messages=[{"role": "user", "content": "Hello!"}],
80
+ )
81
+
82
+ meter.track_anthropic("cust_123", response)
83
+ ```
84
+
85
+ ## Manual Tracking (any provider)
86
+
87
+ ```python
88
+ meter.track(
89
+ customer_id="cust_123",
90
+ model_id="gemini-1.5-pro",
91
+ provider="google",
92
+ input_tokens=2000,
93
+ output_tokens=500,
94
+ request_id="req_abc123",
95
+ span_id="span_7f3a", # link to your tracing
96
+ session_id="sess_456", # group by conversation
97
+ latency_ms=890,
98
+ environment="production",
99
+ metadata={"feature": "code-review", "team": "platform"},
100
+ )
101
+ ```
102
+
103
+ ## Configuration
104
+
105
+ ```python
106
+ meter = FluxMeter(
107
+ kafka_brokers="kafka1:9092,kafka2:9092", # Kafka cluster
108
+ topic="token-events", # Topic name (default)
109
+ environment="production", # Applied to all events
110
+ producer_config={ # Extra Kafka producer config
111
+ "security.protocol": "SASL_SSL",
112
+ "sasl.mechanisms": "PLAIN",
113
+ "sasl.username": "...",
114
+ "sasl.password": "...",
115
+ },
116
+ )
117
+ ```
118
+
119
+ ## How It Works
120
+
121
+ ```
122
+ Your App → meter.track(...) → Kafka → Flink (real-time aggregation) → Redis
123
+
124
+ Grafana / API
125
+ ```
126
+
127
+ Events are batched and compressed (lz4) before sending. The SDK flushes automatically on process exit.
128
+
129
+ ## Requirements
130
+
131
+ - Python 3.9+
132
+ - `confluent-kafka` (librdkafka-based, high performance)
133
+ - FluxMeter infrastructure running (Kafka + Flink + Redis)
@@ -0,0 +1,107 @@
1
+ # FluxMeter Python SDK
2
+
3
+ Send AI token usage events to FluxMeter for real-time aggregation and billing.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install fluxmeter
9
+ ```
10
+
11
+ ## Quick Start (3 lines)
12
+
13
+ ```python
14
+ from fluxmeter import FluxMeter
15
+
16
+ meter = FluxMeter(kafka_brokers="localhost:9094")
17
+ meter.track("cust_123", "gpt-4o", input_tokens=500, output_tokens=150)
18
+ ```
19
+
20
+ ## OpenAI Integration
21
+
22
+ ```python
23
+ import time
24
+ from openai import OpenAI
25
+ from fluxmeter import FluxMeter
26
+
27
+ client = OpenAI()
28
+ meter = FluxMeter(kafka_brokers="localhost:9094", environment="production")
29
+
30
+ start = time.time()
31
+ response = client.chat.completions.create(
32
+ model="gpt-4o",
33
+ messages=[{"role": "user", "content": "Hello!"}],
34
+ )
35
+ latency = int((time.time() - start) * 1000)
36
+
37
+ # One line to meter the usage
38
+ meter.track_openai("cust_123", response, latency_ms=latency)
39
+ ```
40
+
41
+ ## Anthropic Integration
42
+
43
+ ```python
44
+ import anthropic
45
+ from fluxmeter import FluxMeter
46
+
47
+ client = anthropic.Anthropic()
48
+ meter = FluxMeter(kafka_brokers="localhost:9094")
49
+
50
+ response = client.messages.create(
51
+ model="claude-sonnet-4-20250514",
52
+ max_tokens=1024,
53
+ messages=[{"role": "user", "content": "Hello!"}],
54
+ )
55
+
56
+ meter.track_anthropic("cust_123", response)
57
+ ```
58
+
59
+ ## Manual Tracking (any provider)
60
+
61
+ ```python
62
+ meter.track(
63
+ customer_id="cust_123",
64
+ model_id="gemini-1.5-pro",
65
+ provider="google",
66
+ input_tokens=2000,
67
+ output_tokens=500,
68
+ request_id="req_abc123",
69
+ span_id="span_7f3a", # link to your tracing
70
+ session_id="sess_456", # group by conversation
71
+ latency_ms=890,
72
+ environment="production",
73
+ metadata={"feature": "code-review", "team": "platform"},
74
+ )
75
+ ```
76
+
77
+ ## Configuration
78
+
79
+ ```python
80
+ meter = FluxMeter(
81
+ kafka_brokers="kafka1:9092,kafka2:9092", # Kafka cluster
82
+ topic="token-events", # Topic name (default)
83
+ environment="production", # Applied to all events
84
+ producer_config={ # Extra Kafka producer config
85
+ "security.protocol": "SASL_SSL",
86
+ "sasl.mechanisms": "PLAIN",
87
+ "sasl.username": "...",
88
+ "sasl.password": "...",
89
+ },
90
+ )
91
+ ```
92
+
93
+ ## How It Works
94
+
95
+ ```
96
+ Your App → meter.track(...) → Kafka → Flink (real-time aggregation) → Redis
97
+
98
+ Grafana / API
99
+ ```
100
+
101
+ Events are batched and compressed (lz4) before sending. The SDK flushes automatically on process exit.
102
+
103
+ ## Requirements
104
+
105
+ - Python 3.9+
106
+ - `confluent-kafka` (librdkafka-based, high performance)
107
+ - FluxMeter infrastructure running (Kafka + Flink + Redis)
@@ -0,0 +1,8 @@
1
+ """FluxMeter — streaming metering SDK for AI token billing."""
2
+
3
+ from fluxmeter.client import FluxMeter
4
+ from fluxmeter.event import TokenEvent
5
+ from fluxmeter.streaming import StreamingWrapper
6
+
7
+ __version__ = "1.0.0"
8
+ __all__ = ["FluxMeter", "TokenEvent", "StreamingWrapper"]
@@ -0,0 +1,398 @@
1
+ """FluxMeter client — sends token usage events to Kafka."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import atexit
8
+ import threading
9
+ import time
10
+ from typing import Optional
11
+
12
+ from confluent_kafka import Producer
13
+
14
+ from fluxmeter.event import TokenEvent
15
+ from fluxmeter.streaming import StreamingWrapper
16
+ from fluxmeter.wal import WriteAheadLog
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class FluxMeter:
22
+ """Main FluxMeter client. Sends token events to Kafka for real-time aggregation.
23
+
24
+ Events are persisted to a local WAL (write-ahead log) BEFORE sending to Kafka.
25
+ If Kafka is unavailable, events accumulate on disk and flush when it recovers.
26
+ This guarantees zero event loss regardless of Kafka availability.
27
+
28
+ Usage:
29
+ from fluxmeter import FluxMeter
30
+
31
+ meter = FluxMeter(kafka_brokers="localhost:9094")
32
+ meter.track(customer_id="cust_123", model_id="gpt-4o", input_tokens=500, output_tokens=150)
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ kafka_brokers: str = "localhost:9094",
38
+ topic: str = "token-events",
39
+ environment: Optional[str] = None,
40
+ producer_config: Optional[dict] = None,
41
+ wal_enabled: bool = True,
42
+ wal_path: str = "~/.fluxmeter/wal",
43
+ ):
44
+ self._topic = topic
45
+ self._environment = environment
46
+ self._delivery_errors = 0
47
+ self._events_sent = 0
48
+ self._wal_enabled = wal_enabled
49
+
50
+ config = {
51
+ "bootstrap.servers": kafka_brokers,
52
+ "linger.ms": 5,
53
+ "batch.num.messages": 10000,
54
+ "compression.type": "lz4",
55
+ "acks": "all", # Wait for all replicas (no data loss on broker crash)
56
+ }
57
+ if producer_config:
58
+ config.update(producer_config)
59
+
60
+ self._producer = Producer(config)
61
+
62
+ # Local WAL: events persisted to disk before Kafka send
63
+ if wal_enabled:
64
+ self._wal = WriteAheadLog(path=wal_path)
65
+ self._flush_thread = threading.Thread(target=self._wal_flush_loop, daemon=True)
66
+ self._flush_thread.start()
67
+ else:
68
+ self._wal = None
69
+
70
+ atexit.register(self.flush)
71
+
72
+ def track(
73
+ self,
74
+ customer_id: str,
75
+ model_id: str,
76
+ *,
77
+ provider: str = "openai",
78
+ input_tokens: int = 0,
79
+ output_tokens: int = 0,
80
+ cache_read_tokens: int = 0,
81
+ cache_write_tokens: int = 0,
82
+ reasoning_tokens: int = 0,
83
+ embedding_tokens: int = 0,
84
+ request_id: Optional[str] = None,
85
+ span_id: Optional[str] = None,
86
+ parent_span_id: Optional[str] = None,
87
+ session_id: Optional[str] = None,
88
+ latency_ms: int = 0,
89
+ environment: Optional[str] = None,
90
+ metadata: Optional[dict[str, str]] = None,
91
+ ) -> TokenEvent:
92
+ """Track a single LLM API call's token usage.
93
+
94
+ Args:
95
+ customer_id: Your customer/tenant identifier.
96
+ model_id: Model name (e.g. "gpt-4o", "claude-sonnet-4").
97
+ provider: Provider name ("openai", "anthropic", "google").
98
+ input_tokens: Prompt/input token count.
99
+ output_tokens: Completion/output token count.
100
+ cache_read_tokens: Cached prompt tokens read.
101
+ cache_write_tokens: Tokens written to prompt cache.
102
+ reasoning_tokens: Internal reasoning tokens (o1/o3).
103
+ embedding_tokens: Embedding tokens.
104
+ request_id: Provider's request ID.
105
+ span_id: Observability span ID.
106
+ session_id: Conversation/session identifier.
107
+ latency_ms: Provider response time in milliseconds.
108
+ environment: Override instance-level environment.
109
+ metadata: Arbitrary key-value pairs.
110
+
111
+ Returns:
112
+ The TokenEvent that was sent.
113
+ """
114
+ event = TokenEvent(
115
+ customer_id=customer_id,
116
+ model_id=model_id,
117
+ provider=provider,
118
+ input_tokens=input_tokens,
119
+ output_tokens=output_tokens,
120
+ cache_read_tokens=cache_read_tokens,
121
+ cache_write_tokens=cache_write_tokens,
122
+ reasoning_tokens=reasoning_tokens,
123
+ embedding_tokens=embedding_tokens,
124
+ request_id=request_id,
125
+ span_id=span_id,
126
+ parent_span_id=parent_span_id,
127
+ session_id=session_id,
128
+ latency_ms=latency_ms,
129
+ environment=environment or self._environment,
130
+ metadata=metadata,
131
+ )
132
+ self._send(event)
133
+ return event
134
+
135
+ def track_openai(
136
+ self,
137
+ customer_id: str,
138
+ response,
139
+ *,
140
+ session_id: Optional[str] = None,
141
+ span_id: Optional[str] = None,
142
+ latency_ms: int = 0,
143
+ environment: Optional[str] = None,
144
+ ) -> TokenEvent:
145
+ """Track usage from an OpenAI ChatCompletion response object.
146
+
147
+ Args:
148
+ customer_id: Your customer/tenant identifier.
149
+ response: OpenAI ChatCompletion response (or dict).
150
+ session_id: Optional conversation session ID.
151
+ span_id: Optional observability span ID.
152
+ latency_ms: Request latency in ms.
153
+ environment: Override instance-level environment.
154
+
155
+ Returns:
156
+ The TokenEvent that was sent.
157
+ """
158
+ # Handle both object and dict responses
159
+ if hasattr(response, "model"):
160
+ model = response.model
161
+ usage = response.usage
162
+ request_id = response.id
163
+ else:
164
+ model = response["model"]
165
+ usage = response["usage"]
166
+ request_id = response.get("id")
167
+
168
+ # Extract token counts from usage
169
+ if hasattr(usage, "prompt_tokens"):
170
+ input_tokens = usage.prompt_tokens or 0
171
+ output_tokens = usage.completion_tokens or 0
172
+ cache_read = getattr(usage, "prompt_tokens_details", None)
173
+ cache_read_tokens = (
174
+ getattr(cache_read, "cached_tokens", 0) if cache_read else 0
175
+ )
176
+ reasoning = getattr(usage, "completion_tokens_details", None)
177
+ reasoning_tokens = (
178
+ getattr(reasoning, "reasoning_tokens", 0) if reasoning else 0
179
+ )
180
+ else:
181
+ input_tokens = usage.get("prompt_tokens", 0)
182
+ output_tokens = usage.get("completion_tokens", 0)
183
+ details = usage.get("prompt_tokens_details", {}) or {}
184
+ cache_read_tokens = details.get("cached_tokens", 0)
185
+ comp_details = usage.get("completion_tokens_details", {}) or {}
186
+ reasoning_tokens = comp_details.get("reasoning_tokens", 0)
187
+
188
+ return self.track(
189
+ customer_id=customer_id,
190
+ model_id=model,
191
+ provider="openai",
192
+ input_tokens=input_tokens,
193
+ output_tokens=output_tokens,
194
+ cache_read_tokens=cache_read_tokens,
195
+ reasoning_tokens=reasoning_tokens,
196
+ request_id=request_id,
197
+ span_id=span_id,
198
+ session_id=session_id,
199
+ latency_ms=latency_ms,
200
+ environment=environment,
201
+ )
202
+
203
+ def track_anthropic(
204
+ self,
205
+ customer_id: str,
206
+ response,
207
+ *,
208
+ session_id: Optional[str] = None,
209
+ span_id: Optional[str] = None,
210
+ latency_ms: int = 0,
211
+ environment: Optional[str] = None,
212
+ ) -> TokenEvent:
213
+ """Track usage from an Anthropic Message response object.
214
+
215
+ Args:
216
+ customer_id: Your customer/tenant identifier.
217
+ response: Anthropic Message response (or dict).
218
+ session_id: Optional conversation session ID.
219
+ span_id: Optional observability span ID.
220
+ latency_ms: Request latency in ms.
221
+ environment: Override instance-level environment.
222
+
223
+ Returns:
224
+ The TokenEvent that was sent.
225
+ """
226
+ if hasattr(response, "model"):
227
+ model = response.model
228
+ usage = response.usage
229
+ request_id = response.id
230
+ else:
231
+ model = response["model"]
232
+ usage = response["usage"]
233
+ request_id = response.get("id")
234
+
235
+ if hasattr(usage, "input_tokens"):
236
+ input_tokens = usage.input_tokens or 0
237
+ output_tokens = usage.output_tokens or 0
238
+ cache_read_tokens = getattr(usage, "cache_read_input_tokens", 0) or 0
239
+ cache_write_tokens = getattr(usage, "cache_creation_input_tokens", 0) or 0
240
+ else:
241
+ input_tokens = usage.get("input_tokens", 0)
242
+ output_tokens = usage.get("output_tokens", 0)
243
+ cache_read_tokens = usage.get("cache_read_input_tokens", 0)
244
+ cache_write_tokens = usage.get("cache_creation_input_tokens", 0)
245
+
246
+ return self.track(
247
+ customer_id=customer_id,
248
+ model_id=model,
249
+ provider="anthropic",
250
+ input_tokens=input_tokens,
251
+ output_tokens=output_tokens,
252
+ cache_read_tokens=cache_read_tokens,
253
+ cache_write_tokens=cache_write_tokens,
254
+ request_id=request_id,
255
+ span_id=span_id,
256
+ session_id=session_id,
257
+ latency_ms=latency_ms,
258
+ environment=environment,
259
+ )
260
+
261
+ def wrap_stream(
262
+ self,
263
+ stream,
264
+ customer_id: str,
265
+ model_id: str,
266
+ *,
267
+ provider: str = "openai",
268
+ input_tokens: int = 0,
269
+ heartbeat_interval_sec: float = 2.0,
270
+ parent_span_id: Optional[str] = None,
271
+ session_id: Optional[str] = None,
272
+ environment: Optional[str] = None,
273
+ ) -> StreamingWrapper:
274
+ """Wrap a streaming LLM response for near-real-time usage tracking.
275
+
276
+ Emits heartbeat events every heartbeat_interval_sec during the stream,
277
+ then a final accurate event when the stream completes.
278
+
279
+ Usage:
280
+ stream = client.chat.completions.create(..., stream=True)
281
+ for chunk in meter.wrap_stream(stream, "cust_1", "gpt-4o"):
282
+ process(chunk)
283
+ # Final event emitted automatically
284
+ """
285
+ return StreamingWrapper(
286
+ stream=stream,
287
+ meter=self,
288
+ customer_id=customer_id,
289
+ model_id=model_id,
290
+ provider=provider,
291
+ input_tokens=input_tokens,
292
+ heartbeat_interval_sec=heartbeat_interval_sec,
293
+ parent_span_id=parent_span_id,
294
+ session_id=session_id,
295
+ environment=environment or self._environment,
296
+ )
297
+
298
+ def _send(self, event: TokenEvent) -> None:
299
+ """Persist event to WAL, then send to Kafka. Zero data loss."""
300
+ event_dict = event.to_dict()
301
+
302
+ if self._wal:
303
+ self._wal.append(event_dict)
304
+ return # WAL flush thread is the sole Kafka sender (no duplicate replay)
305
+
306
+ try:
307
+ value = json.dumps(event_dict, separators=(",", ":")).encode("utf-8")
308
+ self._producer.produce(
309
+ topic=self._topic,
310
+ key=event.customer_id.encode("utf-8"),
311
+ value=value,
312
+ on_delivery=self._on_delivery,
313
+ )
314
+ self._events_sent += 1
315
+ self._producer.poll(0)
316
+ except (BufferError, Exception) as e:
317
+ self._delivery_errors += 1
318
+ logger.debug("Kafka send failed: %s", e)
319
+
320
+ def _produce_event(self, evt: dict) -> bool:
321
+ """Send one event to Kafka and wait for broker ack. Returns False on failure."""
322
+ value = json.dumps(evt, separators=(",", ":")).encode("utf-8")
323
+ customer_id = evt.get("customerId", "unknown")
324
+ for _ in range(2):
325
+ try:
326
+ self._producer.produce(
327
+ topic=self._topic,
328
+ key=customer_id.encode("utf-8"),
329
+ value=value,
330
+ on_delivery=self._on_delivery,
331
+ )
332
+ self._producer.flush(timeout=10)
333
+ self._events_sent += 1
334
+ return True
335
+ except BufferError:
336
+ self._producer.flush(timeout=10)
337
+ except Exception as e:
338
+ self._delivery_errors += 1
339
+ logger.debug("Kafka send failed: %s", e)
340
+ return False
341
+ self._delivery_errors += 1
342
+ return False
343
+
344
+ def _flush_wal_once(self) -> bool:
345
+ """Send at most one pending WAL event across all files. Returns True if one was sent."""
346
+ if not self._wal:
347
+ return False
348
+ for f in self._wal.pending_files():
349
+ offset = self._wal.get_send_offset(f)
350
+ evt, new_offset = self._wal.read_next_event_from_offset(f, offset)
351
+ if evt is None:
352
+ if f != self._wal._current_file and self._wal.is_fully_sent(f):
353
+ self._wal.mark_flushed(f, 0)
354
+ continue
355
+ if not self._produce_event(evt):
356
+ return False
357
+ self._wal.advance_send_offset(f, new_offset)
358
+ if f != self._wal._current_file and self._wal.is_fully_sent(f):
359
+ self._wal.mark_flushed(f, 1)
360
+ return True
361
+ return False
362
+
363
+ def _wal_flush_loop(self) -> None:
364
+ """Background thread: sends pending WAL events to Kafka one at a time."""
365
+ while True:
366
+ time.sleep(1)
367
+ if not self._wal:
368
+ break
369
+ try:
370
+ while self._flush_wal_once():
371
+ pass
372
+ except Exception as e:
373
+ logger.debug("WAL flush error: %s", e)
374
+
375
+ def _on_delivery(self, err, msg):
376
+ if err:
377
+ self._delivery_errors += 1
378
+ logger.debug("FluxMeter delivery failed: %s", err)
379
+
380
+ def flush(self, timeout: float = 10.0) -> None:
381
+ """Flush pending events. Drains WAL before closing."""
382
+ if self._wal:
383
+ deadline = time.time() + timeout
384
+ while time.time() < deadline and self._flush_wal_once():
385
+ pass
386
+ self._producer.flush(timeout=timeout)
387
+ if self._wal:
388
+ self._wal.close()
389
+
390
+ @property
391
+ def events_sent(self) -> int:
392
+ """Total events sent (including buffered)."""
393
+ return self._events_sent
394
+
395
+ @property
396
+ def delivery_errors(self) -> int:
397
+ """Total delivery failures."""
398
+ return self._delivery_errors
@@ -0,0 +1,85 @@
1
+ """Token usage event model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ import uuid
7
+ from dataclasses import dataclass, field, asdict
8
+ from typing import Optional
9
+
10
+
11
+ @dataclass
12
+ class TokenEvent:
13
+ """Represents one LLM API call's token usage.
14
+
15
+ Supports OpenAI, Anthropic, Google, and custom providers.
16
+ All token fields are optional — set what's available from your provider response.
17
+ """
18
+
19
+ customer_id: str
20
+ model_id: str
21
+ provider: str = "openai"
22
+
23
+ # Token counts
24
+ input_tokens: int = 0
25
+ output_tokens: int = 0
26
+ cache_read_tokens: int = 0
27
+ cache_write_tokens: int = 0
28
+ reasoning_tokens: int = 0
29
+ embedding_tokens: int = 0
30
+
31
+ # Identity & tracing
32
+ event_id: str = field(default_factory=lambda: str(uuid.uuid4()))
33
+ request_id: Optional[str] = None
34
+ span_id: Optional[str] = None
35
+ parent_span_id: Optional[str] = None # Links child LLM calls to parent agent run
36
+ session_id: Optional[str] = None
37
+
38
+ # Timing
39
+ timestamp: int = field(default_factory=lambda: int(time.time() * 1000))
40
+ latency_ms: int = 0
41
+
42
+ # Context
43
+ environment: Optional[str] = None
44
+ metadata: Optional[dict[str, str]] = None
45
+
46
+ def to_dict(self) -> dict:
47
+ """Serialize to dict with camelCase keys (matches Java consumer)."""
48
+ d = {
49
+ "eventId": self.event_id,
50
+ "customerId": self.customer_id,
51
+ "provider": self.provider,
52
+ "modelId": self.model_id,
53
+ "inputTokens": self.input_tokens,
54
+ "outputTokens": self.output_tokens,
55
+ "cacheReadTokens": self.cache_read_tokens,
56
+ "cacheWriteTokens": self.cache_write_tokens,
57
+ "reasoningTokens": self.reasoning_tokens,
58
+ "embeddingTokens": self.embedding_tokens,
59
+ "timestamp": self.timestamp,
60
+ "latencyMs": self.latency_ms,
61
+ }
62
+ if self.request_id:
63
+ d["requestId"] = self.request_id
64
+ if self.span_id:
65
+ d["spanId"] = self.span_id
66
+ if self.parent_span_id:
67
+ d["parentSpanId"] = self.parent_span_id
68
+ if self.session_id:
69
+ d["sessionId"] = self.session_id
70
+ if self.environment:
71
+ d["environment"] = self.environment
72
+ if self.metadata:
73
+ d["metadata"] = self.metadata
74
+ return d
75
+
76
+ @property
77
+ def total_tokens(self) -> int:
78
+ return (
79
+ self.input_tokens
80
+ + self.output_tokens
81
+ + self.cache_read_tokens
82
+ + self.cache_write_tokens
83
+ + self.reasoning_tokens
84
+ + self.embedding_tokens
85
+ )
@@ -0,0 +1,157 @@
1
+ """Streaming response wrapper for FluxMeter.
2
+
3
+ Wraps OpenAI/Anthropic streaming responses to emit partial usage events
4
+ during the stream (heartbeat every N chunks or every interval_sec).
5
+ Provides near-real-time visibility into long-running LLM calls.
6
+
7
+ Usage:
8
+ from fluxmeter import FluxMeter
9
+
10
+ meter = FluxMeter(kafka_brokers="localhost:9094")
11
+
12
+ # OpenAI streaming
13
+ stream = client.chat.completions.create(model="gpt-4o", messages=[...], stream=True)
14
+ for chunk in meter.wrap_stream(stream, customer_id="cust_1", model_id="gpt-4o"):
15
+ process(chunk)
16
+ # Final usage event emitted automatically on stream end
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import time
22
+ from typing import Iterator, Optional, Any
23
+
24
+ from fluxmeter.event import TokenEvent
25
+
26
+
27
+ class StreamingWrapper:
28
+ """Wraps a streaming LLM response iterator with usage tracking.
29
+
30
+ Counts output tokens (approximated from chunks) and emits partial
31
+ usage events at regular intervals. Emits a final event on stream end.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ stream: Iterator[Any],
37
+ meter, # FluxMeter instance
38
+ customer_id: str,
39
+ model_id: str,
40
+ provider: str = "openai",
41
+ input_tokens: int = 0,
42
+ heartbeat_interval_sec: float = 2.0,
43
+ parent_span_id: Optional[str] = None,
44
+ session_id: Optional[str] = None,
45
+ environment: Optional[str] = None,
46
+ ):
47
+ self._stream = stream
48
+ self._meter = meter
49
+ self._customer_id = customer_id
50
+ self._model_id = model_id
51
+ self._provider = provider
52
+ self._input_tokens = input_tokens
53
+ self._heartbeat_interval = heartbeat_interval_sec
54
+ self._parent_span_id = parent_span_id
55
+ self._session_id = session_id
56
+ self._environment = environment
57
+
58
+ self._output_chunks = 0
59
+ self._estimated_output_tokens = 0
60
+ self._last_emitted_output_tokens = 0
61
+ self._last_heartbeat = time.time()
62
+ self._start_time = time.time()
63
+ self._finished = False
64
+ self._request_id: Optional[str] = None
65
+
66
+ def __iter__(self):
67
+ return self
68
+
69
+ def __next__(self):
70
+ try:
71
+ chunk = next(self._stream)
72
+ self._process_chunk(chunk)
73
+
74
+ # Emit heartbeat if interval elapsed
75
+ now = time.time()
76
+ if now - self._last_heartbeat >= self._heartbeat_interval:
77
+ self._emit_heartbeat()
78
+ self._last_heartbeat = now
79
+
80
+ return chunk
81
+ except StopIteration:
82
+ self._emit_final()
83
+ raise
84
+
85
+ def _process_chunk(self, chunk) -> None:
86
+ """Extract token info from a streaming chunk."""
87
+ self._output_chunks += 1
88
+
89
+ # OpenAI: chunk.choices[0].delta.content
90
+ if hasattr(chunk, "choices") and chunk.choices:
91
+ delta = getattr(chunk.choices[0], "delta", None)
92
+ if delta and getattr(delta, "content", None):
93
+ # Approximate: ~0.75 tokens per character for English
94
+ self._estimated_output_tokens += max(1, len(delta.content) // 4)
95
+ if not self._request_id and hasattr(chunk, "id"):
96
+ self._request_id = chunk.id
97
+
98
+ # Anthropic: chunk.type == "content_block_delta", chunk.delta.text
99
+ elif hasattr(chunk, "type") and chunk.type == "content_block_delta":
100
+ text = getattr(getattr(chunk, "delta", None), "text", "")
101
+ if text:
102
+ self._estimated_output_tokens += max(1, len(text) // 4)
103
+
104
+ # OpenAI final chunk with usage
105
+ if hasattr(chunk, "usage") and chunk.usage:
106
+ usage = chunk.usage
107
+ if hasattr(usage, "completion_tokens") and usage.completion_tokens:
108
+ self._estimated_output_tokens = usage.completion_tokens
109
+ if hasattr(usage, "prompt_tokens") and usage.prompt_tokens:
110
+ self._input_tokens = usage.prompt_tokens
111
+
112
+ def _emit_heartbeat(self) -> None:
113
+ """Emit a partial usage event (heartbeat) during streaming."""
114
+ delta = self._estimated_output_tokens - self._last_emitted_output_tokens
115
+ if delta <= 0:
116
+ return
117
+ self._last_emitted_output_tokens = self._estimated_output_tokens
118
+ self._meter.track(
119
+ customer_id=self._customer_id,
120
+ model_id=self._model_id,
121
+ provider=self._provider,
122
+ input_tokens=0, # Only count input once in final event
123
+ output_tokens=delta,
124
+ parent_span_id=self._parent_span_id,
125
+ session_id=self._session_id,
126
+ environment=self._environment,
127
+ metadata={"_heartbeat": "true", "_chunks": str(self._output_chunks)},
128
+ )
129
+
130
+ def _emit_final(self) -> None:
131
+ """Emit the final usage event with accurate totals on stream end."""
132
+ if self._finished:
133
+ return
134
+ self._finished = True
135
+
136
+ latency_ms = int((time.time() - self._start_time) * 1000)
137
+ self._meter.track(
138
+ customer_id=self._customer_id,
139
+ model_id=self._model_id,
140
+ provider=self._provider,
141
+ input_tokens=self._input_tokens,
142
+ output_tokens=self._estimated_output_tokens,
143
+ request_id=self._request_id,
144
+ parent_span_id=self._parent_span_id,
145
+ session_id=self._session_id,
146
+ latency_ms=latency_ms,
147
+ environment=self._environment,
148
+ metadata={"_stream_chunks": str(self._output_chunks)},
149
+ )
150
+
151
+ @property
152
+ def estimated_output_tokens(self) -> int:
153
+ return self._estimated_output_tokens
154
+
155
+ @property
156
+ def elapsed_ms(self) -> int:
157
+ return int((time.time() - self._start_time) * 1000)
@@ -0,0 +1,174 @@
1
+ """Write-Ahead Log for FluxMeter SDK.
2
+
3
+ Events are persisted to a local append-only file BEFORE sending to Kafka.
4
+ If Kafka is unavailable, events accumulate on disk and flush when it recovers.
5
+ This guarantees no event loss regardless of Kafka availability.
6
+
7
+ File format: one JSON object per line (newline-delimited JSON / NDJSON).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import logging
14
+ import os
15
+ import threading
16
+ import time
17
+ from pathlib import Path
18
+ from typing import Optional
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class WriteAheadLog:
24
+ """Append-only local event buffer with background flush to Kafka."""
25
+
26
+ def __init__(
27
+ self,
28
+ path: str = "~/.fluxmeter/wal",
29
+ max_file_size_mb: int = 100,
30
+ flush_interval_sec: float = 1.0,
31
+ ):
32
+ self._dir = Path(os.path.expanduser(path))
33
+ self._dir.mkdir(parents=True, exist_ok=True)
34
+ self._max_file_size = max_file_size_mb * 1024 * 1024
35
+ self._flush_interval = flush_interval_sec
36
+
37
+ self._current_file: Optional[Path] = None
38
+ self._file_handle = None
39
+ self._lock = threading.Lock()
40
+ self._pending_count = 0
41
+ self._flushed_count = 0
42
+ # Byte offset successfully sent to Kafka per file (avoids duplicate replay)
43
+ self._send_offsets: dict[str, int] = {}
44
+
45
+ self._rotate_if_needed()
46
+
47
+ def append(self, event_dict: dict) -> None:
48
+ """Append event to WAL. Returns immediately. Thread-safe.
49
+ Batch fsync: every 100 events or 500ms, whichever comes first."""
50
+ line = json.dumps(event_dict, separators=(",", ":")) + "\n"
51
+ with self._lock:
52
+ self._rotate_if_needed()
53
+ self._file_handle.write(line)
54
+ self._file_handle.flush()
55
+ self._pending_count += 1
56
+ if self._pending_count % 100 == 0:
57
+ os.fsync(self._file_handle.fileno())
58
+
59
+ def pending_files(self) -> list[Path]:
60
+ """List WAL files that may have unsent events (oldest first)."""
61
+ files = sorted(self._dir.glob("wal-*.jsonl"))
62
+ return files
63
+
64
+ def get_send_offset(self, file_path: Path) -> int:
65
+ """Return byte offset of last successfully sent event in this file."""
66
+ return self._send_offsets.get(str(file_path), 0)
67
+
68
+ def advance_send_offset(self, file_path: Path, new_offset: int) -> None:
69
+ """Record how many bytes have been successfully sent from file_path."""
70
+ with self._lock:
71
+ self._send_offsets[str(file_path)] = new_offset
72
+
73
+ def read_next_event_from_offset(
74
+ self, file_path: Path, byte_offset: int
75
+ ) -> tuple[dict | None, int]:
76
+ """Read at most one event from byte_offset. Returns (event, new_offset)."""
77
+ try:
78
+ with open(file_path, "r") as f:
79
+ f.seek(byte_offset)
80
+ line = f.readline()
81
+ if not line:
82
+ return None, byte_offset
83
+ stripped = line.strip()
84
+ if not stripped:
85
+ return None, f.tell()
86
+ try:
87
+ return json.loads(stripped), f.tell()
88
+ except json.JSONDecodeError:
89
+ return None, f.tell()
90
+ except FileNotFoundError:
91
+ return None, byte_offset
92
+
93
+ def read_events_from_offset(self, file_path: Path, byte_offset: int) -> tuple[list[dict], int]:
94
+ """Read events starting at byte_offset. Returns (events, new_byte_offset)."""
95
+ events: list[dict] = []
96
+ new_offset = byte_offset
97
+ try:
98
+ with open(file_path, "r") as f:
99
+ f.seek(byte_offset)
100
+ while True:
101
+ line_start = f.tell()
102
+ line = f.readline()
103
+ if not line:
104
+ break
105
+ stripped = line.strip()
106
+ if stripped:
107
+ try:
108
+ events.append(json.loads(stripped))
109
+ new_offset = f.tell()
110
+ except json.JSONDecodeError:
111
+ new_offset = f.tell()
112
+ continue
113
+ else:
114
+ new_offset = line_start + len(line)
115
+ except FileNotFoundError:
116
+ pass
117
+ return events, new_offset
118
+
119
+ def is_fully_sent(self, file_path: Path) -> bool:
120
+ """True if all bytes in file have been sent to Kafka."""
121
+ try:
122
+ size = file_path.stat().st_size
123
+ except FileNotFoundError:
124
+ return True
125
+ return self.get_send_offset(file_path) >= size and size > 0
126
+
127
+ def mark_flushed(self, file_path: Path, count: int) -> None:
128
+ """Mark a WAL file as fully flushed to Kafka. Deletes it."""
129
+ with self._lock:
130
+ if file_path == self._current_file:
131
+ return
132
+ key = str(file_path)
133
+ self._send_offsets.pop(key, None)
134
+ try:
135
+ file_path.unlink()
136
+ self._flushed_count += count
137
+ except FileNotFoundError:
138
+ pass
139
+
140
+ def read_events(self, file_path: Path) -> list[dict]:
141
+ """Read all events from a WAL file."""
142
+ events, _ = self.read_events_from_offset(file_path, 0)
143
+ return events
144
+
145
+ def _rotate_if_needed(self) -> None:
146
+ """Create a new WAL file if current is too large or doesn't exist."""
147
+ if self._file_handle and self._current_file:
148
+ try:
149
+ size = self._current_file.stat().st_size
150
+ if size < self._max_file_size:
151
+ return
152
+ except FileNotFoundError:
153
+ pass
154
+
155
+ if self._file_handle:
156
+ self._file_handle.close()
157
+
158
+ ts = int(time.time() * 1000)
159
+ self._current_file = self._dir / f"wal-{ts}.jsonl"
160
+ self._file_handle = open(self._current_file, "a")
161
+
162
+ @property
163
+ def pending_count(self) -> int:
164
+ return self._pending_count
165
+
166
+ @property
167
+ def flushed_count(self) -> int:
168
+ return self._flushed_count
169
+
170
+ def close(self) -> None:
171
+ with self._lock:
172
+ if self._file_handle:
173
+ self._file_handle.close()
174
+ self._file_handle = None
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "fluxmeter"
7
+ version = "1.0.0"
8
+ description = "Python SDK for FluxMeter — streaming metering for AI token billing"
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "FluxMeter", email = "hello@fluxmeter.dev" }]
13
+ keywords = ["ai", "llm", "metering", "billing", "tokens", "openai", "anthropic", "streaming"]
14
+ classifiers = [
15
+ "Development Status :: 5 - Production/Stable",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: Apache Software License",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Software Development :: Libraries",
20
+ ]
21
+ dependencies = [
22
+ "confluent-kafka>=2.3.0",
23
+ ]
24
+
25
+ [project.optional-dependencies]
26
+ openai = ["openai>=1.0"]
27
+ anthropic = ["anthropic>=0.20"]
28
+ dev = ["pytest", "pytest-asyncio", "ruff"]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/10kshuaizhang/fluxmeter"
32
+ Repository = "https://github.com/10kshuaizhang/fluxmeter"
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["fluxmeter"]
36
+
37
+ [tool.ruff]
38
+ target-version = "py39"
39
+ line-length = 100
File without changes
@@ -0,0 +1,69 @@
1
+ """Tests for FluxMeter client (provider response parsing)."""
2
+
3
+ from unittest.mock import patch, MagicMock
4
+ from fluxmeter.client import FluxMeter
5
+
6
+
7
+ def _mock_meter():
8
+ """Create a FluxMeter with mocked Kafka producer."""
9
+ with patch("fluxmeter.client.Producer") as mock_producer_cls:
10
+ mock_producer = MagicMock()
11
+ mock_producer_cls.return_value = mock_producer
12
+ meter = FluxMeter(kafka_brokers="localhost:9094", wal_enabled=False)
13
+ return meter, mock_producer
14
+
15
+
16
+ def test_track_basic():
17
+ meter, producer = _mock_meter()
18
+ event = meter.track("cust_1", "gpt-4o", input_tokens=100, output_tokens=50)
19
+ assert event.customer_id == "cust_1"
20
+ assert event.model_id == "gpt-4o"
21
+ assert event.input_tokens == 100
22
+ assert event.output_tokens == 50
23
+ assert producer.produce.called
24
+
25
+
26
+ def test_track_openai_dict_response():
27
+ meter, producer = _mock_meter()
28
+ response = {
29
+ "id": "chatcmpl-abc123",
30
+ "model": "gpt-4o-2024-08-06",
31
+ "usage": {
32
+ "prompt_tokens": 1200,
33
+ "completion_tokens": 350,
34
+ "prompt_tokens_details": {"cached_tokens": 200},
35
+ "completion_tokens_details": {"reasoning_tokens": 0},
36
+ },
37
+ }
38
+ event = meter.track_openai("cust_42", response, latency_ms=1200)
39
+ assert event.customer_id == "cust_42"
40
+ assert event.model_id == "gpt-4o-2024-08-06"
41
+ assert event.provider == "openai"
42
+ assert event.input_tokens == 1200
43
+ assert event.output_tokens == 350
44
+ assert event.cache_read_tokens == 200
45
+ assert event.request_id == "chatcmpl-abc123"
46
+ assert event.latency_ms == 1200
47
+
48
+
49
+ def test_track_anthropic_dict_response():
50
+ meter, producer = _mock_meter()
51
+ response = {
52
+ "id": "msg_abc123",
53
+ "model": "claude-sonnet-4-20250514",
54
+ "usage": {
55
+ "input_tokens": 800,
56
+ "output_tokens": 200,
57
+ "cache_read_input_tokens": 150,
58
+ "cache_creation_input_tokens": 50,
59
+ },
60
+ }
61
+ event = meter.track_anthropic("cust_99", response)
62
+ assert event.customer_id == "cust_99"
63
+ assert event.model_id == "claude-sonnet-4-20250514"
64
+ assert event.provider == "anthropic"
65
+ assert event.input_tokens == 800
66
+ assert event.output_tokens == 200
67
+ assert event.cache_read_tokens == 150
68
+ assert event.cache_write_tokens == 50
69
+ assert event.request_id == "msg_abc123"
@@ -0,0 +1,61 @@
1
+ """Tests for TokenEvent serialization."""
2
+
3
+ from fluxmeter.event import TokenEvent
4
+
5
+
6
+ def test_to_dict_camel_case():
7
+ event = TokenEvent(
8
+ customer_id="cust_1",
9
+ model_id="gpt-4o",
10
+ provider="openai",
11
+ input_tokens=100,
12
+ output_tokens=50,
13
+ )
14
+ d = event.to_dict()
15
+ assert d["customerId"] == "cust_1"
16
+ assert d["modelId"] == "gpt-4o"
17
+ assert d["provider"] == "openai"
18
+ assert d["inputTokens"] == 100
19
+ assert d["outputTokens"] == 50
20
+ assert "eventId" in d
21
+ assert "timestamp" in d
22
+
23
+
24
+ def test_total_tokens():
25
+ event = TokenEvent(
26
+ customer_id="cust_1",
27
+ model_id="o1",
28
+ input_tokens=1000,
29
+ output_tokens=500,
30
+ reasoning_tokens=3000,
31
+ cache_read_tokens=200,
32
+ )
33
+ assert event.total_tokens == 4700
34
+
35
+
36
+ def test_optional_fields_excluded():
37
+ event = TokenEvent(customer_id="cust_1", model_id="gpt-4o")
38
+ d = event.to_dict()
39
+ assert "requestId" not in d
40
+ assert "spanId" not in d
41
+ assert "sessionId" not in d
42
+ assert "environment" not in d
43
+ assert "metadata" not in d
44
+
45
+
46
+ def test_optional_fields_included():
47
+ event = TokenEvent(
48
+ customer_id="cust_1",
49
+ model_id="gpt-4o",
50
+ request_id="chatcmpl-abc",
51
+ span_id="span_123",
52
+ session_id="sess_456",
53
+ environment="production",
54
+ metadata={"feature": "chat"},
55
+ )
56
+ d = event.to_dict()
57
+ assert d["requestId"] == "chatcmpl-abc"
58
+ assert d["spanId"] == "span_123"
59
+ assert d["sessionId"] == "sess_456"
60
+ assert d["environment"] == "production"
61
+ assert d["metadata"] == {"feature": "chat"}
@@ -0,0 +1,71 @@
1
+ """Tests for WAL duplicate-send prevention and flush semantics."""
2
+
3
+ from unittest.mock import MagicMock, patch
4
+ import tempfile
5
+
6
+ from fluxmeter.client import FluxMeter
7
+ from fluxmeter.wal import WriteAheadLog
8
+
9
+
10
+ def test_wal_single_send_path():
11
+ """Reading from offset after advance returns no duplicate events."""
12
+ with tempfile.TemporaryDirectory() as tmpdir:
13
+ wal = WriteAheadLog(path=tmpdir, flush_interval_sec=0.1)
14
+ wal.append({"eventId": "evt-1", "customerId": "c1", "modelId": "gpt-4o"})
15
+ wal.append({"eventId": "evt-2", "customerId": "c1", "modelId": "gpt-4o"})
16
+
17
+ f = wal.pending_files()[0]
18
+ evt1, off1 = wal.read_next_event_from_offset(f, 0)
19
+ assert evt1["eventId"] == "evt-1"
20
+ wal.advance_send_offset(f, off1)
21
+ evt2, off2 = wal.read_next_event_from_offset(f, off1)
22
+ assert evt2["eventId"] == "evt-2"
23
+ wal.advance_send_offset(f, off2)
24
+ evt3, _ = wal.read_next_event_from_offset(f, off2)
25
+ assert evt3 is None
26
+
27
+
28
+ def test_wal_enabled_no_immediate_kafka():
29
+ """With WAL on, _send does not call produce directly."""
30
+ with patch("fluxmeter.client.Producer") as mock_cls:
31
+ mock_producer = MagicMock()
32
+ mock_cls.return_value = mock_producer
33
+ with tempfile.TemporaryDirectory() as tmpdir:
34
+ meter = FluxMeter(
35
+ kafka_brokers="localhost:9094",
36
+ wal_enabled=True,
37
+ wal_path=tmpdir,
38
+ )
39
+ meter.track("cust_1", "gpt-4o", input_tokens=10, output_tokens=5)
40
+ assert not mock_producer.produce.called
41
+
42
+
43
+ def test_flush_drains_wal_before_close():
44
+ """flush() sends WAL events synchronously before closing."""
45
+ with patch("fluxmeter.client.Producer") as mock_cls:
46
+ mock_producer = MagicMock()
47
+ mock_cls.return_value = mock_producer
48
+ with tempfile.TemporaryDirectory() as tmpdir:
49
+ meter = FluxMeter(
50
+ kafka_brokers="localhost:9094",
51
+ wal_enabled=True,
52
+ wal_path=tmpdir,
53
+ )
54
+ meter.track("cust_1", "gpt-4o", input_tokens=1, output_tokens=1)
55
+ meter.flush(timeout=5.0)
56
+ assert mock_producer.produce.call_count == 1
57
+ mock_producer.flush.assert_called()
58
+
59
+
60
+ def test_partial_send_advances_one_event_at_a_time():
61
+ """First event ack advances offset; second event remains for retry."""
62
+ with tempfile.TemporaryDirectory() as tmpdir:
63
+ wal = WriteAheadLog(path=tmpdir)
64
+ f = wal._current_file
65
+ wal.append({"eventId": "a", "customerId": "c1"})
66
+ wal.append({"eventId": "b", "customerId": "c1"})
67
+
68
+ _, off1 = wal.read_next_event_from_offset(f, 0)
69
+ wal.advance_send_offset(f, off1)
70
+ evt_b, _ = wal.read_next_event_from_offset(f, off1)
71
+ assert evt_b["eventId"] == "b"