metergraphrelay 0.7.1__tar.gz → 0.7.2__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.
Files changed (44) hide show
  1. {metergraphrelay-0.7.1/src/metergraphrelay.egg-info → metergraphrelay-0.7.2}/PKG-INFO +4 -1
  2. {metergraphrelay-0.7.1 → metergraphrelay-0.7.2}/README.md +3 -0
  3. {metergraphrelay-0.7.1 → metergraphrelay-0.7.2}/pyproject.toml +1 -1
  4. metergraphrelay-0.7.2/src/metergraphrelay/__init__.py +1 -0
  5. metergraphrelay-0.7.2/src/metergraphrelay/billing_evidence.py +43 -0
  6. {metergraphrelay-0.7.1 → metergraphrelay-0.7.2}/src/metergraphrelay/portkey_sync.py +14 -5
  7. {metergraphrelay-0.7.1 → metergraphrelay-0.7.2}/src/metergraphrelay/providers/braintrust.py +2 -0
  8. {metergraphrelay-0.7.1 → metergraphrelay-0.7.2}/src/metergraphrelay/providers/langfuse.py +48 -8
  9. {metergraphrelay-0.7.1 → metergraphrelay-0.7.2}/src/metergraphrelay/providers/langsmith.py +2 -0
  10. {metergraphrelay-0.7.1 → metergraphrelay-0.7.2}/src/metergraphrelay/providers/openai.py +35 -0
  11. {metergraphrelay-0.7.1 → metergraphrelay-0.7.2}/src/metergraphrelay/providers/portkey.py +205 -3
  12. {metergraphrelay-0.7.1 → metergraphrelay-0.7.2}/src/metergraphrelay/providers/portkey_export.py +1 -1
  13. {metergraphrelay-0.7.1 → metergraphrelay-0.7.2/src/metergraphrelay.egg-info}/PKG-INFO +4 -1
  14. {metergraphrelay-0.7.1 → metergraphrelay-0.7.2}/src/metergraphrelay.egg-info/SOURCES.txt +2 -0
  15. metergraphrelay-0.7.2/tests/test_billing_evidence.py +39 -0
  16. {metergraphrelay-0.7.1 → metergraphrelay-0.7.2}/tests/test_portkey_sync.py +59 -0
  17. metergraphrelay-0.7.1/src/metergraphrelay/__init__.py +0 -1
  18. {metergraphrelay-0.7.1 → metergraphrelay-0.7.2}/LICENSE +0 -0
  19. {metergraphrelay-0.7.1 → metergraphrelay-0.7.2}/setup.cfg +0 -0
  20. {metergraphrelay-0.7.1 → metergraphrelay-0.7.2}/src/metergraphrelay/capture_contract.py +0 -0
  21. {metergraphrelay-0.7.1 → metergraphrelay-0.7.2}/src/metergraphrelay/cli.py +0 -0
  22. {metergraphrelay-0.7.1 → metergraphrelay-0.7.2}/src/metergraphrelay/config.py +0 -0
  23. {metergraphrelay-0.7.1 → metergraphrelay-0.7.2}/src/metergraphrelay/demo.py +0 -0
  24. {metergraphrelay-0.7.1 → metergraphrelay-0.7.2}/src/metergraphrelay/import_identity.py +0 -0
  25. {metergraphrelay-0.7.1 → metergraphrelay-0.7.2}/src/metergraphrelay/metergraph_sync.py +0 -0
  26. {metergraphrelay-0.7.1 → metergraphrelay-0.7.2}/src/metergraphrelay/provider_sync.py +0 -0
  27. {metergraphrelay-0.7.1 → metergraphrelay-0.7.2}/src/metergraphrelay/providers/__init__.py +0 -0
  28. {metergraphrelay-0.7.1 → metergraphrelay-0.7.2}/src/metergraphrelay/providers/phoenix.py +0 -0
  29. {metergraphrelay-0.7.1 → metergraphrelay-0.7.2}/src/metergraphrelay/push.py +0 -0
  30. {metergraphrelay-0.7.1 → metergraphrelay-0.7.2}/src/metergraphrelay/py.typed +0 -0
  31. {metergraphrelay-0.7.1 → metergraphrelay-0.7.2}/src/metergraphrelay/sync_core.py +0 -0
  32. {metergraphrelay-0.7.1 → metergraphrelay-0.7.2}/src/metergraphrelay/window.py +0 -0
  33. {metergraphrelay-0.7.1 → metergraphrelay-0.7.2}/src/metergraphrelay.egg-info/dependency_links.txt +0 -0
  34. {metergraphrelay-0.7.1 → metergraphrelay-0.7.2}/src/metergraphrelay.egg-info/entry_points.txt +0 -0
  35. {metergraphrelay-0.7.1 → metergraphrelay-0.7.2}/src/metergraphrelay.egg-info/requires.txt +0 -0
  36. {metergraphrelay-0.7.1 → metergraphrelay-0.7.2}/src/metergraphrelay.egg-info/top_level.txt +0 -0
  37. {metergraphrelay-0.7.1 → metergraphrelay-0.7.2}/tests/test_capture_contract.py +0 -0
  38. {metergraphrelay-0.7.1 → metergraphrelay-0.7.2}/tests/test_cli.py +0 -0
  39. {metergraphrelay-0.7.1 → metergraphrelay-0.7.2}/tests/test_config.py +0 -0
  40. {metergraphrelay-0.7.1 → metergraphrelay-0.7.2}/tests/test_demo.py +0 -0
  41. {metergraphrelay-0.7.1 → metergraphrelay-0.7.2}/tests/test_metergraph_sync.py +0 -0
  42. {metergraphrelay-0.7.1 → metergraphrelay-0.7.2}/tests/test_provider_sync.py +0 -0
  43. {metergraphrelay-0.7.1 → metergraphrelay-0.7.2}/tests/test_push.py +0 -0
  44. {metergraphrelay-0.7.1 → metergraphrelay-0.7.2}/tests/test_window.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: metergraphrelay
3
- Version: 0.7.1
3
+ Version: 0.7.2
4
4
  Summary: Move LLM trace data between systems: pull from providers, push to metergraph.
5
5
  Author: Vasiliy Radostev
6
6
  License-Expression: MIT
@@ -551,6 +551,9 @@ records**, the run splits it **once** into **10 sub-windows with
551
551
  absorbs the boundary overlaps). This split is one-shot, never recursive:
552
552
  if any single sub-window *still* exceeds 50,000 records, the run fails
553
553
  with a clear error rather than splitting further.
554
+ Before splitting, the run cancels the never-started hourly draft on a best effort
555
+ basis. If Portkey refuses the cancel, the run warns and continues because an
556
+ unstarted draft only counts rows and exports nothing.
554
557
 
555
558
  **Before running this against your own data:** as in manual mode,
556
559
  request and response content is uploaded to metergraph, with no opt-out.
@@ -528,6 +528,9 @@ records**, the run splits it **once** into **10 sub-windows with
528
528
  absorbs the boundary overlaps). This split is one-shot, never recursive:
529
529
  if any single sub-window *still* exceeds 50,000 records, the run fails
530
530
  with a clear error rather than splitting further.
531
+ Before splitting, the run cancels the never-started hourly draft on a best effort
532
+ basis. If Portkey refuses the cancel, the run warns and continues because an
533
+ unstarted draft only counts rows and exports nothing.
531
534
 
532
535
  **Before running this against your own data:** as in manual mode,
533
536
  request and response content is uploaded to metergraph, with no opt-out.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "metergraphrelay"
7
- version = "0.7.1"
7
+ version = "0.7.2"
8
8
  description = "Move LLM trace data between systems: pull from providers, push to metergraph."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1 @@
1
+ __version__ = "0.7.2"
@@ -0,0 +1,43 @@
1
+ """How an importer states where a cost figure came from.
2
+
3
+ metergraph selects an effective cost from its own catalog and from amounts a
4
+ source reported. It can only weigh a reported amount if it knows what produced
5
+ it, because the field carrying one is open: a figure a customer's own client
6
+ wrote and a figure a gateway billed arrive in the same place and look alike.
7
+
8
+ Two kinds of source report a cost, and they are not equivalent:
9
+
10
+ * A **gateway** sits in the request path and states what the call cost. Naming
11
+ it lets that amount be treated as billing evidence.
12
+ * An **observability platform** reports its own estimate, computed from the
13
+ tokens it observed against a price table it maintains. Braintrust calls the
14
+ field ``estimated_cost``, which is the honest description of all of them.
15
+
16
+ So an observability platform names its source and no gateway, while a gateway
17
+ states itself on the row. The amount is still recorded either way, and an audit
18
+ can compare it against a computed cost, but nothing can mistake an estimate for
19
+ something the provider charged.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from decimal import Decimal, InvalidOperation
25
+ from typing import Any
26
+
27
+
28
+ def reported_cost(amount: Any, *, source: str) -> dict:
29
+ """The fields naming a reported cost, or nothing when there is no amount.
30
+
31
+ The amount is carried as a decimal string. A cost lands in a numeric column,
32
+ and a value divided or parsed as a binary float arrives carrying rounding
33
+ that no later step can remove.
34
+ """
35
+ if amount is None or isinstance(amount, bool):
36
+ return {}
37
+ try:
38
+ parsed = Decimal(str(amount))
39
+ except (InvalidOperation, TypeError, ValueError):
40
+ return {}
41
+ if not parsed.is_finite() or parsed < 0:
42
+ return {}
43
+ return {"reported_cost_usd": str(parsed), "reported_cost_source": source}
@@ -26,6 +26,7 @@ boundaries force an unconditional renewal.
26
26
  from __future__ import annotations
27
27
 
28
28
  import os
29
+ import sys
29
30
  import tempfile
30
31
  import time
31
32
  from typing import Callable
@@ -182,9 +183,9 @@ def run_portkey_sync(
182
183
  def _plan_exports(pk_client, lease, created_export_ids: list[str], renewer) -> list[str]:
183
184
  """Create the hourly draft; its ``total`` decides whether to split. No start/poll here.
184
185
 
185
- ``total > threshold`` cancels the still-unstarted hourly draft and creates
186
- exactly 10 overlapping sub-window drafts. If any sub-window draft is itself
187
- still oversized, reject with a clear error — the MVP never splits recursively.
186
+ ``total > threshold`` best-effort cancels the still-unstarted hourly draft and
187
+ creates exactly 10 overlapping sub-window drafts. If any sub-window draft is
188
+ itself still oversized, reject with a clear error — the MVP never splits recursively.
188
189
  Each create/cancel can be slow, so the lease is renewed on a time cadence
189
190
  between them.
190
191
  """
@@ -196,8 +197,16 @@ def _plan_exports(pk_client, lease, created_export_ids: list[str], renewer) -> l
196
197
  if hourly.total is None or hourly.total <= VOLUME_SPLIT_THRESHOLD:
197
198
  return [hourly.export_id]
198
199
 
199
- # Oversized: cancel the still-unstarted hourly draft, then split into exactly 10.
200
- pk_client.cancel_export(hourly.export_id)
200
+ # Oversized: best-effort cancel the still-unstarted hourly draft, then split into
201
+ # exactly 10. An unstarted draft costs nothing, so a cancel refusal is non-fatal.
202
+ try:
203
+ pk_client.cancel_export(hourly.export_id)
204
+ except (PortkeyExportError, OSError) as exc:
205
+ print(
206
+ f"Warning: could not cancel export {hourly.export_id}: {exc}; the draft "
207
+ "was never started so it is left in place.",
208
+ file=sys.stderr,
209
+ )
201
210
  created_export_ids.remove(hourly.export_id)
202
211
  renewer.tick()
203
212
  # split_window validates the server-provided bounds (aware, end-after-start). A
@@ -9,6 +9,7 @@ import urllib.request
9
9
  from typing import Any, Callable
10
10
 
11
11
  from .. import __version__
12
+ from ..billing_evidence import reported_cost
12
13
  from ..capture_contract import capture_response_text, capture_row, capture_tool_calls
13
14
  from ..import_identity import ImportContext, canonical_import_event_id
14
15
  from ..window import normalize_utc_designator
@@ -513,6 +514,7 @@ def normalize_span(
513
514
  "reasoning_tokens": usage["reasoning_tokens"],
514
515
  "latency_ms": usage["latency_ms"],
515
516
  "cost_usd": _cost_usd(span, metrics),
517
+ **reported_cost(_cost_usd(span, metrics), source="braintrust.estimated_cost"),
516
518
  "error": error,
517
519
  "error_type": _error_type(raw_error) if error else None,
518
520
  "request_id": span["id"],
@@ -11,6 +11,7 @@ import urllib.request
11
11
  from typing import Any, Callable
12
12
 
13
13
  from .. import __version__
14
+ from ..billing_evidence import reported_cost
14
15
  from ..capture_contract import capture_response_text, capture_row
15
16
  from ..import_identity import ImportContext, canonical_import_event_id
16
17
 
@@ -297,6 +298,19 @@ _REASONING_KEYS = (
297
298
  )
298
299
  _NON_BUCKET_MARKERS = ("priority",)
299
300
 
301
+ # Providers whose own usage reports input *excluding* the cached tokens, which
302
+ # it lists separately. metergraph prices cache handling per publisher, so a row
303
+ # has to arrive in the shape that publisher's API uses: for one of these,
304
+ # folding the cache buckets into input makes those tokens part of the input
305
+ # total as well as a bucket of their own, and they are billed twice -- once at
306
+ # the input rate and once at the cache rate, an order of magnitude apart.
307
+ #
308
+ # For OpenAI and Google the opposite is true: their own totals include the
309
+ # cached tokens, so the buckets Langfuse subtracted do have to be added back.
310
+ _CACHE_EXCLUSIVE_INPUT_PROVIDERS = frozenset(
311
+ {"anthropic", "bedrock", "aws-bedrock", "aws", "amazon-bedrock"}
312
+ )
313
+
300
314
 
301
315
  def _is_int(value: Any) -> bool:
302
316
  return isinstance(value, int) and not isinstance(value, bool)
@@ -315,17 +329,26 @@ def _first_int(source: dict[str, Any], keys: tuple[str, ...]) -> int | None:
315
329
 
316
330
 
317
331
  def _total_with_buckets(
318
- usage_details: dict[str, Any], total_keys: tuple[str, ...], marker: str
332
+ usage_details: dict[str, Any],
333
+ total_keys: tuple[str, ...],
334
+ marker: str,
335
+ *,
336
+ skip_keys: tuple[str, ...] = (),
319
337
  ) -> int | None:
320
- """The named total plus every other integer bucket carrying ``marker``."""
338
+ """The named total plus every other integer bucket carrying ``marker``.
339
+
340
+ ``skip_keys`` names buckets the total already excludes by the provider's own
341
+ convention, so adding them would count those tokens twice.
342
+ """
321
343
  total_key, total = _first_int_key(usage_details, total_keys)
322
344
  if total_key is None:
323
345
  return None
346
+ skipped = {key.lower() for key in skip_keys}
324
347
  for key, value in usage_details.items():
325
348
  if key == total_key or not _is_int(value):
326
349
  continue
327
350
  lowered = key.lower()
328
- if marker not in lowered:
351
+ if marker not in lowered or lowered in skipped:
329
352
  continue
330
353
  if any(skip in lowered for skip in _NON_BUCKET_MARKERS):
331
354
  continue
@@ -333,8 +356,15 @@ def _total_with_buckets(
333
356
  return total
334
357
 
335
358
 
336
- def map_usage_details(usage_details: Any) -> dict[str, int | None]:
337
- """Map one observation's usageDetails onto metergraph token fields."""
359
+ def map_usage_details(
360
+ usage_details: Any, *, provider: str | None = None
361
+ ) -> dict[str, int | None]:
362
+ """Map one observation's usageDetails onto metergraph token fields.
363
+
364
+ ``provider`` decides whether the cache buckets belong inside the input
365
+ total, because that is a property of the provider's own usage shape rather
366
+ than of Langfuse's.
367
+ """
338
368
  if not isinstance(usage_details, dict):
339
369
  return {
340
370
  "input_tokens": None,
@@ -343,8 +373,16 @@ def map_usage_details(usage_details: Any) -> dict[str, int | None]:
343
373
  "cache_write_tokens": None,
344
374
  "reasoning_tokens": None,
345
375
  }
376
+ cache_exclusive = (
377
+ str(provider or "").strip().lower() in _CACHE_EXCLUSIVE_INPUT_PROVIDERS
378
+ )
346
379
  return {
347
- "input_tokens": _total_with_buckets(usage_details, _INPUT_TOTAL_KEYS, "input"),
380
+ "input_tokens": _total_with_buckets(
381
+ usage_details,
382
+ _INPUT_TOTAL_KEYS,
383
+ "input",
384
+ skip_keys=(_CACHE_READ_KEYS + _CACHE_WRITE_KEYS) if cache_exclusive else (),
385
+ ),
348
386
  "output_tokens": _total_with_buckets(usage_details, _OUTPUT_TOTAL_KEYS, "output"),
349
387
  "cache_read_tokens": _first_int(usage_details, _CACHE_READ_KEYS),
350
388
  "cache_write_tokens": _first_int(usage_details, _CACHE_WRITE_KEYS),
@@ -382,7 +420,8 @@ def normalize_observation(
382
420
  raw_status_message if error and isinstance(raw_status_message, str) else None
383
421
  )
384
422
 
385
- usage = map_usage_details(observation.get("usageDetails"))
423
+ provider = infer_provider(observation)
424
+ usage = map_usage_details(observation.get("usageDetails"), provider=provider)
386
425
  request_json, request_text = _map_content(observation.get("input"))
387
426
  response_text = _response_text(observation.get("output"))
388
427
 
@@ -393,7 +432,7 @@ def normalize_observation(
393
432
  "source": "langfuse",
394
433
  "sdk": "metergraphrelay",
395
434
  "sdk_version": __version__,
396
- "provider": infer_provider(observation),
435
+ "provider": provider,
397
436
  "model": model,
398
437
  "status": "error" if error else "success",
399
438
  "input_tokens": usage["input_tokens"],
@@ -402,6 +441,7 @@ def normalize_observation(
402
441
  "cache_write_tokens": usage["cache_write_tokens"],
403
442
  "reasoning_tokens": usage["reasoning_tokens"],
404
443
  "cost_usd": observation.get("totalCost"),
444
+ **reported_cost(observation.get("totalCost"), source="langfuse.observation.totalCost"),
405
445
  "error": error,
406
446
  "error_type": error_type,
407
447
  "request_id": observation["id"],
@@ -39,6 +39,7 @@ from datetime import datetime
39
39
  from typing import Any, Callable
40
40
 
41
41
  from .. import __version__
42
+ from ..billing_evidence import reported_cost
42
43
  from ..capture_contract import capture_response_text, capture_row
43
44
  from ..import_identity import ImportContext, canonical_import_event_id
44
45
  from ..window import normalize_utc_designator
@@ -473,6 +474,7 @@ def normalize_run(
473
474
  "reasoning_tokens": usage["reasoning_tokens"],
474
475
  "latency_ms": _latency_ms(run.get("start_time"), run.get("end_time")),
475
476
  "cost_usd": _cost_usd(run),
477
+ **reported_cost(_cost_usd(run), source="langsmith.total_cost"),
476
478
  "error": error,
477
479
  "error_type": error_type,
478
480
  "request_id": run_id,
@@ -24,6 +24,40 @@ def _as_mapping(call: Any) -> Any:
24
24
  return getattr(call, "__dict__", {})
25
25
 
26
26
 
27
+ def _usage_int(usage: Any, group: str, field: str) -> int | None:
28
+ """One nested usage count, when the provider recorded it.
29
+
30
+ Absent and zero are different facts: a provider reporting 0 says the feature
31
+ was off for that call, where a missing count says nothing at all and must
32
+ not be read as zero.
33
+ """
34
+ detail = getattr(usage, group, None)
35
+ value = getattr(detail, field, None)
36
+ if value is None and isinstance(detail, dict):
37
+ value = detail.get(field)
38
+ if isinstance(value, bool) or not isinstance(value, int):
39
+ return None
40
+ return value
41
+
42
+
43
+ def _usage_detail(usage: Any) -> dict[str, int]:
44
+ """Counts that sit beside the totals and are billed at their own rates.
45
+
46
+ `prompt_tokens` already includes the cached tokens, so a row without the
47
+ cache count bills them at the full input rate rather than the far cheaper
48
+ cache rate. Reasoning tokens are already inside `completion_tokens` and are
49
+ carried as detail only, never added to the total.
50
+ """
51
+ detail = {}
52
+ cached = _usage_int(usage, "prompt_tokens_details", "cached_tokens")
53
+ if cached is not None:
54
+ detail["cache_read_tokens"] = cached
55
+ reasoning = _usage_int(usage, "completion_tokens_details", "reasoning_tokens")
56
+ if reasoning is not None:
57
+ detail["reasoning_tokens"] = reasoning
58
+ return detail
59
+
60
+
27
61
  def normalize_completion(
28
62
  completion: Any,
29
63
  messages: Iterable[Any],
@@ -66,6 +100,7 @@ def normalize_completion(
66
100
  "endpoint": "chat.completions",
67
101
  "input_tokens": getattr(usage, "prompt_tokens", None) if usage else None,
68
102
  "output_tokens": getattr(usage, "completion_tokens", None) if usage else None,
103
+ **_usage_detail(usage),
69
104
  "error": content_fetch_error is not None,
70
105
  "error_type": (
71
106
  type(content_fetch_error).__name__ if content_fetch_error else None
@@ -5,10 +5,12 @@ import math
5
5
  import re
6
6
  import sys
7
7
  from dataclasses import dataclass
8
+ from decimal import Decimal
8
9
  from datetime import datetime, timedelta, timezone
9
10
  from typing import Any, Callable
10
11
 
11
12
  from .. import __version__
13
+ from ..billing_evidence import reported_cost
12
14
  from ..capture_contract import capture_row, capture_text, capture_tool_calls
13
15
 
14
16
 
@@ -237,6 +239,198 @@ def _timestamp_from_epoch(numeric: float, raw: Any) -> datetime:
237
239
  ) from exc
238
240
 
239
241
 
242
+ # Portkey passes the upstream provider's usage block through untouched, so one
243
+ # export carries every vendor's spelling of the same counts. Each is resolved by
244
+ # path and the first present one wins. Reasoning tokens are already inside
245
+ # res_units, so they are detail only and never added to output_tokens.
246
+ _CACHE_READ_PATHS = (
247
+ ("input_tokens_details", "cached_tokens"),
248
+ ("prompt_tokens_details", "cached_tokens"),
249
+ ("cache_read_input_tokens",),
250
+ )
251
+ _CACHE_WRITE_PATHS = (
252
+ ("cache_creation_input_tokens",),
253
+ ("input_tokens_details", "cache_write_tokens"),
254
+ )
255
+ # Some responses carry the TTL split in place of the aggregate, so summing the
256
+ # split is the only way to see the total.
257
+ _CACHE_WRITE_5M_PATH = ("cache_creation", "ephemeral_5m_input_tokens")
258
+ _CACHE_WRITE_1H_PATH = ("cache_creation", "ephemeral_1h_input_tokens")
259
+ _CACHE_WRITE_TTL_PATHS = (_CACHE_WRITE_5M_PATH, _CACHE_WRITE_1H_PATH)
260
+ _REASONING_PATHS = (
261
+ ("output_tokens_details", "reasoning_tokens"),
262
+ ("completion_tokens_details", "reasoning_tokens"),
263
+ )
264
+
265
+
266
+ def _is_int(value: Any) -> bool:
267
+ return isinstance(value, int) and not isinstance(value, bool)
268
+
269
+
270
+ def _dig(source: Any, path: tuple[str, ...]) -> Any:
271
+ """Follow a key path through nested dicts, returning None off the path.
272
+
273
+ The usage block is provider-supplied and reaches us through an error path as
274
+ readily as a success one, so any level may be missing or not a dict.
275
+ """
276
+ current = source
277
+ for key in path:
278
+ if not isinstance(current, dict):
279
+ return None
280
+ current = current.get(key)
281
+ return current
282
+
283
+
284
+ def _first_int(source: Any, paths: tuple[tuple[str, ...], ...]) -> int | None:
285
+ for path in paths:
286
+ value = _dig(source, path)
287
+ if _is_int(value):
288
+ return value
289
+ return None
290
+
291
+
292
+ def _first_present(source: Any, paths: tuple[tuple[str, ...], ...]) -> Any:
293
+ for path in paths:
294
+ value = _dig(source, path)
295
+ if value is not None:
296
+ return value
297
+ return None
298
+
299
+
300
+ def _sum_ints(source: Any, paths: tuple[tuple[str, ...], ...]) -> int | None:
301
+ values = [value for path in paths if _is_int(value := _dig(source, path))]
302
+ return sum(values) if values else None
303
+
304
+
305
+ def _usage_detail(response: dict) -> dict[str, Any]:
306
+ """Usage detail from a Portkey row's response, present keys only.
307
+
308
+ Absent and zero price differently, so a detail nobody reported stays absent
309
+ rather than becoming a plausible-looking 0 that hides a capture regression.
310
+ """
311
+ usage = response.get("usage")
312
+ detail: dict[str, Any] = {}
313
+
314
+ cache_read = _first_int(usage, _CACHE_READ_PATHS)
315
+ if cache_read is not None:
316
+ detail["cache_read_tokens"] = cache_read
317
+
318
+ write_5m = _dig(usage, _CACHE_WRITE_5M_PATH)
319
+ if _is_int(write_5m):
320
+ detail["cache_write_5m_tokens"] = write_5m
321
+ write_1h = _dig(usage, _CACHE_WRITE_1H_PATH)
322
+ if _is_int(write_1h):
323
+ detail["cache_write_1h_tokens"] = write_1h
324
+
325
+ cache_write = _first_int(usage, _CACHE_WRITE_PATHS)
326
+ if cache_write is None:
327
+ # Only the TTL split was reported: sum it so a consumer that reads just
328
+ # cache_write_tokens still sees every written token.
329
+ cache_write = _sum_ints(usage, _CACHE_WRITE_TTL_PATHS)
330
+ if cache_write is not None:
331
+ detail["cache_write_tokens"] = cache_write
332
+
333
+ reasoning = _first_int(usage, _REASONING_PATHS)
334
+ if reasoning is not None:
335
+ detail["reasoning_tokens"] = reasoning
336
+
337
+ # Premium tier and region are request metadata, not counts, and they are
338
+ # what makes priority/batch and regional pricing answerable at all. The
339
+ # ingest writer keeps unrecognised keys in calls.meta, so they survive
340
+ # without a schema change.
341
+ service_tier = _first_present(
342
+ {"response": response, "usage": usage},
343
+ (("response", "service_tier"), ("usage", "service_tier")),
344
+ )
345
+ if service_tier is not None:
346
+ detail["service_tier"] = service_tier
347
+
348
+ inference_geo = _dig(usage, ("inference_geo",))
349
+ if inference_geo is not None:
350
+ detail["inference_geo"] = inference_geo
351
+
352
+ server_tool_use = _dig(usage, ("server_tool_use",))
353
+ if isinstance(server_tool_use, dict) and server_tool_use:
354
+ detail["server_tool_use"] = server_tool_use
355
+
356
+ grounding_queries = _grounding_queries(response)
357
+ if grounding_queries is not None:
358
+ detail["grounding_queries"] = grounding_queries
359
+
360
+ searches = _web_search_calls(response)
361
+ if searches is not None:
362
+ detail["web_search_calls"] = searches
363
+
364
+ return detail
365
+
366
+
367
+ def _web_search_calls(response: dict) -> int | None:
368
+ """How many searches the model ran, for providers that bill per search.
369
+
370
+ The count is in the output the model produced, not in `usage`, and a single
371
+ call runs several. It is a whole charge of its own: on this traffic it is
372
+ priced per search at a rate the token rates cannot express.
373
+ """
374
+ output = response.get("output")
375
+ if not isinstance(output, list):
376
+ return None
377
+ return sum(
378
+ 1
379
+ for item in output
380
+ if isinstance(item, dict) and item.get("type") == "web_search_call"
381
+ )
382
+
383
+
384
+ def _cost_usd_from_cents(cents: Any) -> Decimal | None:
385
+ """A row's cost in dollars, or None when it does not state a usable one.
386
+
387
+ Portkey states the cost in cents. Dividing in decimal keeps the value exact:
388
+ it ends up in a numeric column, and binary rounding introduced here survives
389
+ the whole way.
390
+
391
+ A bool is an int in Python, and ``Decimal("True")`` raises an error the
392
+ export converter does not catch, so one such row would end the window rather
393
+ than convert without a cost. A non-finite value is not a cost either.
394
+ """
395
+ if isinstance(cents, bool) or not isinstance(cents, (int, float)):
396
+ return None
397
+ value = Decimal(str(cents))
398
+ return value / 100 if value.is_finite() else None
399
+
400
+
401
+ def _endpoint(response: dict) -> str | None:
402
+ """Which provider API the call went to, as the billing evidence names it.
403
+
404
+ A gateway's reported cost is only interpretable alongside the endpoint that
405
+ produced it, because the same gateway prices its endpoints differently.
406
+ """
407
+ if response.get("object") == "response":
408
+ return "responses"
409
+ if isinstance(response.get("choices"), list):
410
+ return "chat.completions"
411
+ return None
412
+
413
+
414
+ def _grounding_queries(response: dict) -> int | None:
415
+ """How many grounding queries a Gemini response ran.
416
+
417
+ Google bills grounding per query, not per prompt, and one prompt runs
418
+ several, so the count cannot be derived from the call count. It sits outside
419
+ `usage`, under each choice's groundingMetadata.
420
+ """
421
+ choices = response.get("choices")
422
+ if not isinstance(choices, list):
423
+ return None
424
+ total = 0
425
+ seen = False
426
+ for choice in choices:
427
+ queries = _dig(choice, ("groundingMetadata", "webSearchQueries"))
428
+ if isinstance(queries, list):
429
+ seen = True
430
+ total += len(queries)
431
+ return total if seen else None
432
+
433
+
240
434
  def normalize_portkey_row(
241
435
  row: dict, *, import_context: ImportContext | None = None
242
436
  ) -> dict:
@@ -274,8 +468,7 @@ def normalize_portkey_row(
274
468
  else "portkey/backfill"
275
469
  )
276
470
 
277
- cost = row.get("cost")
278
- cost_usd = cost / 100 if isinstance(cost, (int, float)) else None
471
+ cost_usd = _cost_usd_from_cents(row.get("cost"))
279
472
 
280
473
  result = {
281
474
  "ts": ts,
@@ -287,7 +480,15 @@ def normalize_portkey_row(
287
480
  "latency_ms": row.get("response_time"),
288
481
  "error": is_error,
289
482
  "error_type": error_type,
290
- "cost_usd": cost_usd,
483
+ # Portkey's own figure, named so it can be recognised as a gateway's
484
+ # amount rather than a number of unknown origin. `cost_usd` stays for
485
+ # application versions that only read the legacy field.
486
+ "cost_usd": str(cost_usd) if cost_usd is not None else None,
487
+ # Stated whether or not a cost came with the row: it is where the call
488
+ # was served, not a property of the amount.
489
+ "gateway": "portkey",
490
+ **reported_cost(cost_usd, source="portkey.cost"),
491
+ "endpoint": _endpoint(response),
291
492
  "request_id": request_id,
292
493
  "span_id": request_id,
293
494
  "trace_id": trace_id,
@@ -301,6 +502,7 @@ def normalize_portkey_row(
301
502
  "sdk_version": __version__,
302
503
  "content_opted_in": True,
303
504
  }
505
+ result.update(_usage_detail(response))
304
506
  if import_context is not None:
305
507
  result["import_source"] = import_context.source
306
508
  result["import_source_scope"] = import_context.source_scope
@@ -195,7 +195,7 @@ class PortkeyExportClient:
195
195
  return PortkeyExport(export_id=returned_id, total=None, status=status)
196
196
 
197
197
  def cancel_export(self, export_id: str) -> None:
198
- self._api_request("POST", self._export_path(export_id, "/cancel"), body={})
198
+ self._api_request("POST", self._export_path(export_id, "/cancel"), body=None)
199
199
 
200
200
  def download_to(
201
201
  self, export_id: str, dest_path: str, *, on_progress: Callable[[], None] | None = None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: metergraphrelay
3
- Version: 0.7.1
3
+ Version: 0.7.2
4
4
  Summary: Move LLM trace data between systems: pull from providers, push to metergraph.
5
5
  Author: Vasiliy Radostev
6
6
  License-Expression: MIT
@@ -551,6 +551,9 @@ records**, the run splits it **once** into **10 sub-windows with
551
551
  absorbs the boundary overlaps). This split is one-shot, never recursive:
552
552
  if any single sub-window *still* exceeds 50,000 records, the run fails
553
553
  with a clear error rather than splitting further.
554
+ Before splitting, the run cancels the never-started hourly draft on a best effort
555
+ basis. If Portkey refuses the cancel, the run warns and continues because an
556
+ unstarted draft only counts rows and exports nothing.
554
557
 
555
558
  **Before running this against your own data:** as in manual mode,
556
559
  request and response content is uploaded to metergraph, with no opt-out.
@@ -2,6 +2,7 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  src/metergraphrelay/__init__.py
5
+ src/metergraphrelay/billing_evidence.py
5
6
  src/metergraphrelay/capture_contract.py
6
7
  src/metergraphrelay/cli.py
7
8
  src/metergraphrelay/config.py
@@ -28,6 +29,7 @@ src/metergraphrelay/providers/openai.py
28
29
  src/metergraphrelay/providers/phoenix.py
29
30
  src/metergraphrelay/providers/portkey.py
30
31
  src/metergraphrelay/providers/portkey_export.py
32
+ tests/test_billing_evidence.py
31
33
  tests/test_capture_contract.py
32
34
  tests/test_cli.py
33
35
  tests/test_config.py
@@ -0,0 +1,39 @@
1
+ """Tests for how an importer states where a cost figure came from."""
2
+
3
+ from decimal import Decimal
4
+
5
+ import pytest
6
+
7
+ from metergraphrelay.billing_evidence import reported_cost
8
+
9
+
10
+ def test_an_amount_is_named_by_its_source():
11
+ assert reported_cost(0.0012, source="langfuse.observation.totalCost") == {
12
+ "reported_cost_usd": "0.0012",
13
+ "reported_cost_source": "langfuse.observation.totalCost",
14
+ }
15
+
16
+
17
+ def test_the_amount_keeps_every_digit_it_was_given():
18
+ """A cost lands in a numeric column, so rounding introduced here survives
19
+ the whole way."""
20
+ assert reported_cost(
21
+ Decimal("19.394766285"), source="portkey.cost"
22
+ )["reported_cost_usd"] == "19.394766285"
23
+
24
+
25
+ @pytest.mark.parametrize("amount", [None, True, False, "", "abc", object()])
26
+ def test_a_source_is_never_named_without_an_amount_behind_it(amount):
27
+ """A named source with no amount would read as a cost of nothing rather than
28
+ as no cost reported."""
29
+ assert reported_cost(amount, source="x.cost") == {}
30
+
31
+
32
+ @pytest.mark.parametrize("amount", [-1, "-0.01", float("inf"), float("nan")])
33
+ def test_an_amount_that_cannot_be_a_cost_is_refused(amount):
34
+ assert reported_cost(amount, source="x.cost") == {}
35
+
36
+
37
+ def test_zero_is_a_cost():
38
+ """Nothing billed is a fact; it is not a missing figure."""
39
+ assert reported_cost(0, source="x.cost")["reported_cost_usd"] == "0"
@@ -413,6 +413,41 @@ def test_over_threshold_cancels_unstarted_hourly_draft_and_splits_into_ten():
413
413
  assert mg.completed == ["lease-1"] # completed only after all ten pushed
414
414
 
415
415
 
416
+ def test_cancel_failure_on_unstarted_hourly_draft_does_not_abort_the_split(capsys):
417
+ full = (WINDOW_START, WINDOW_END)
418
+
419
+ class CancelFailure(FakePortkey):
420
+ def cancel_export(self, export_id):
421
+ self.cancelled.append(export_id)
422
+ raise PortkeyExportError(
423
+ "Portkey export request failed: HTTP 400 Bad Request"
424
+ )
425
+
426
+ pk = CancelFailure({}, totals={full: VOLUME_SPLIT_THRESHOLD + 1})
427
+ original_create = pk.create_export
428
+
429
+ def seeding_create(*, window_start, window_end):
430
+ pk._rows.setdefault((window_start, window_end), [_portkey_row(f"r-{window_start}")])
431
+ return original_create(window_start=window_start, window_end=window_end)
432
+
433
+ pk.create_export = seeding_create
434
+ mg = FakeMeterGraph(_acquired())
435
+
436
+ outcome = _run(mg, pk, [])
437
+
438
+ assert outcome.status == "completed"
439
+ assert outcome.exit_code == 0
440
+ assert pk.cancelled == ["exp-1"]
441
+ assert len(pk.created) == 1 + 10
442
+ assert pk.started == [f"exp-{i}" for i in range(2, 12)]
443
+ assert mg.completed == ["lease-1"]
444
+ assert mg.abandoned == []
445
+ stderr = capsys.readouterr().err
446
+ assert "Warning" in stderr
447
+ assert "exp-1" in stderr
448
+ assert "400" in stderr
449
+
450
+
416
451
  def test_boundary_total_exactly_at_threshold_does_not_split():
417
452
  # total == 50_000 is NOT over the threshold: the hourly draft is used as-is.
418
453
  full = (WINDOW_START, WINDOW_END)
@@ -445,6 +480,30 @@ def test_subwindow_still_over_threshold_is_rejected_without_recursion():
445
480
  assert str(VOLUME_SPLIT_THRESHOLD) in outcome.detail or "recursiv" in outcome.detail.lower()
446
481
 
447
482
 
483
+ def test_failed_hourly_cancel_is_not_retried_during_later_cleanup():
484
+ full = (WINDOW_START, WINDOW_END)
485
+
486
+ class CancelFailure(FakePortkey):
487
+ def cancel_export(self, export_id):
488
+ self.cancelled.append(export_id)
489
+ if export_id == "exp-1":
490
+ raise PortkeyExportError(
491
+ "Portkey export request failed: HTTP 400 Bad Request"
492
+ )
493
+
494
+ pk = CancelFailure({}, totals={full: VOLUME_SPLIT_THRESHOLD + 1},
495
+ default_total=VOLUME_SPLIT_THRESHOLD + 1)
496
+ mg = FakeMeterGraph(_acquired())
497
+
498
+ outcome = _run(mg, pk, [])
499
+
500
+ assert outcome.status == "failed"
501
+ assert outcome.exit_code == 1
502
+ assert mg.completed == []
503
+ assert mg.abandoned == ["lease-1"]
504
+ assert pk.cancelled.count("exp-1") == 1
505
+
506
+
448
507
  def test_planning_valueerror_from_bad_window_bounds_is_handled_failure():
449
508
  # The server hands out reversed window bounds and an oversized draft, so the
450
509
  # split planner calls split_window with end <= start and it raises ValueError.
@@ -1 +0,0 @@
1
- __version__ = "0.7.1"
File without changes