metergraphrelay 0.7.0__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.0/src/metergraphrelay.egg-info → metergraphrelay-0.7.2}/PKG-INFO +4 -1
  2. {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/README.md +3 -0
  3. {metergraphrelay-0.7.0 → 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.0 → metergraphrelay-0.7.2}/src/metergraphrelay/capture_contract.py +50 -4
  7. {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay/portkey_sync.py +14 -5
  8. {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay/providers/braintrust.py +7 -7
  9. {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay/providers/langfuse.py +51 -10
  10. {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay/providers/langsmith.py +5 -2
  11. {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay/providers/openai.py +65 -4
  12. {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay/providers/phoenix.py +3 -2
  13. {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay/providers/portkey.py +207 -5
  14. {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay/providers/portkey_export.py +1 -1
  15. {metergraphrelay-0.7.0 → metergraphrelay-0.7.2/src/metergraphrelay.egg-info}/PKG-INFO +4 -1
  16. {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay.egg-info/SOURCES.txt +2 -0
  17. metergraphrelay-0.7.2/tests/test_billing_evidence.py +39 -0
  18. {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/tests/test_capture_contract.py +31 -0
  19. {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/tests/test_portkey_sync.py +59 -0
  20. metergraphrelay-0.7.0/src/metergraphrelay/__init__.py +0 -1
  21. {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/LICENSE +0 -0
  22. {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/setup.cfg +0 -0
  23. {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay/cli.py +0 -0
  24. {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay/config.py +0 -0
  25. {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay/demo.py +0 -0
  26. {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay/import_identity.py +0 -0
  27. {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay/metergraph_sync.py +0 -0
  28. {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay/provider_sync.py +0 -0
  29. {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay/providers/__init__.py +0 -0
  30. {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay/push.py +0 -0
  31. {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay/py.typed +0 -0
  32. {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay/sync_core.py +0 -0
  33. {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay/window.py +0 -0
  34. {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay.egg-info/dependency_links.txt +0 -0
  35. {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay.egg-info/entry_points.txt +0 -0
  36. {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay.egg-info/requires.txt +0 -0
  37. {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/src/metergraphrelay.egg-info/top_level.txt +0 -0
  38. {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/tests/test_cli.py +0 -0
  39. {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/tests/test_config.py +0 -0
  40. {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/tests/test_demo.py +0 -0
  41. {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/tests/test_metergraph_sync.py +0 -0
  42. {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/tests/test_provider_sync.py +0 -0
  43. {metergraphrelay-0.7.0 → metergraphrelay-0.7.2}/tests/test_push.py +0 -0
  44. {metergraphrelay-0.7.0 → 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.0
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.0"
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}
@@ -14,6 +14,9 @@ Two rules bind an importer:
14
14
  which the pipeline reads as native-search audit and passes over.
15
15
  * ``response_text`` is a string. A response carrying no text is ``""``; ``None``
16
16
  means the result itself is malformed.
17
+ * A numeric the source never recorded is absent, not ``None``. The pipeline
18
+ validates ``latency_ms``, ``input_tokens`` and ``output_tokens`` whenever the
19
+ key is present, so an explicit ``None`` reads as a malformed number.
17
20
 
18
21
  Provider wire items that are neither a tool call nor search audit -- OpenAI
19
22
  ``reasoning`` items are the case seen in production -- have no representation in
@@ -30,6 +33,11 @@ from typing import Any
30
33
  # exempt from the tool-call shape. Passed through with their fields intact.
31
34
  NATIVE_SEARCH_AUDIT_TYPES = frozenset({"web_search_call", "web_fetch_call"})
32
35
 
36
+ # Validated by the pipeline on presence, not on value: carrying one of these as
37
+ # an explicit None says "this is the number" and fails, where leaving it out
38
+ # says "the source did not record one" and is accepted.
39
+ PRESENCE_CHECKED_NUMERIC_FIELDS = ("latency_ms", "input_tokens", "output_tokens")
40
+
33
41
 
34
42
  def capture_text(parts: list[str]) -> str:
35
43
  """Join a response's text parts into the contract's ``response_text``.
@@ -40,6 +48,21 @@ def capture_text(parts: list[str]) -> str:
40
48
  return "\n".join(parts)
41
49
 
42
50
 
51
+ def capture_response_text(value: Any, *, content_opted_in: bool = True) -> str | None:
52
+ """A row's ``response_text``, as the contract requires it.
53
+
54
+ ``None`` is reserved for a row carrying no content at all, which the
55
+ pipeline recognises as the opt-out. On a row whose content *was* captured it
56
+ means something else entirely -- a malformed result -- and takes the row out
57
+ of every analysis. A reply with nothing to say is ``""``.
58
+ """
59
+ if not content_opted_in:
60
+ return None
61
+ if value is None:
62
+ return ""
63
+ return value if isinstance(value, str) else json.dumps(value)
64
+
65
+
43
66
  def _arguments(value: Any) -> str:
44
67
  """Render a tool call's arguments as the contract's JSON string."""
45
68
  if isinstance(value, str):
@@ -49,11 +72,18 @@ def _arguments(value: Any) -> str:
49
72
  return json.dumps(value)
50
73
 
51
74
 
52
- def capture_tool_call(item: Any) -> dict | None:
75
+ def capture_tool_call(item: Any, *, position: int | None = None) -> dict | None:
53
76
  """Convert one provider tool item to the contract shape.
54
77
 
55
78
  Returns the item unchanged when it is native-search audit, and ``None`` for
56
79
  anything with no contract representation, which the caller drops.
80
+
81
+ ``position`` supplies a fallback identity for a recognised tool call whose
82
+ source recorded no id. Providers that log whatever an integration handed
83
+ them do occasionally omit it, and dropping the call would describe a tool
84
+ turn as though the model had replied with text alone. The contract needs the
85
+ id only to tell one call from another within a response, which the position
86
+ does. A call with no name is still dropped: there is nothing to identify.
57
87
  """
58
88
  if not isinstance(item, dict):
59
89
  return None
@@ -82,10 +112,12 @@ def capture_tool_call(item: Any) -> dict | None:
82
112
  else:
83
113
  return None
84
114
 
85
- if not isinstance(call_id, str) or not call_id.strip():
86
- return None
87
115
  if not isinstance(name, str) or not name.strip():
88
116
  return None
117
+ if not isinstance(call_id, str) or not call_id.strip():
118
+ if position is None:
119
+ return None
120
+ call_id = f"tool-{position}"
89
121
  return {"call_id": call_id, "name": name, "arguments": _arguments(arguments)}
90
122
 
91
123
 
@@ -94,6 +126,20 @@ def capture_tool_calls(items: Any) -> list | None:
94
126
  if not isinstance(items, (list, tuple)):
95
127
  return None
96
128
  converted = [
97
- call for item in items if (call := capture_tool_call(item)) is not None
129
+ call
130
+ for position, item in enumerate(items)
131
+ if (call := capture_tool_call(item, position=position)) is not None
98
132
  ]
99
133
  return converted or None
134
+
135
+
136
+ def capture_row(row: dict) -> dict:
137
+ """Drop the numerics a source never recorded, so the row stays readable.
138
+
139
+ Every other field is passed through untouched.
140
+ """
141
+ return {
142
+ key: value
143
+ for key, value in row.items()
144
+ if value is not None or key not in PRESENCE_CHECKED_NUMERIC_FIELDS
145
+ }
@@ -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,8 @@ import urllib.request
9
9
  from typing import Any, Callable
10
10
 
11
11
  from .. import __version__
12
+ from ..billing_evidence import reported_cost
13
+ from ..capture_contract import capture_response_text, capture_row, capture_tool_calls
12
14
  from ..import_identity import ImportContext, canonical_import_event_id
13
15
  from ..window import normalize_utc_designator
14
16
 
@@ -279,14 +281,11 @@ def _content_blocks(blocks: list) -> tuple[str | None, list | None]:
279
281
  text_parts.append(block["text"])
280
282
  elif block.get("type") == "tool_use":
281
283
  tool_calls.append(block)
282
- return ("\n".join(text_parts) or None), (tool_calls or None)
284
+ return ("\n".join(text_parts) or None), capture_tool_calls(tool_calls)
283
285
 
284
286
 
285
287
  def _message_output(message: dict[str, Any]) -> tuple[str | None, list | None]:
286
- raw_tool_calls = message.get("tool_calls")
287
- tool_calls = (
288
- raw_tool_calls if isinstance(raw_tool_calls, list) and raw_tool_calls else None
289
- )
288
+ tool_calls = capture_tool_calls(message.get("tool_calls"))
290
289
  content = message.get("content")
291
290
  if isinstance(content, str):
292
291
  return (content or None), tool_calls
@@ -515,6 +514,7 @@ def normalize_span(
515
514
  "reasoning_tokens": usage["reasoning_tokens"],
516
515
  "latency_ms": usage["latency_ms"],
517
516
  "cost_usd": _cost_usd(span, metrics),
517
+ **reported_cost(_cost_usd(span, metrics), source="braintrust.estimated_cost"),
518
518
  "error": error,
519
519
  "error_type": _error_type(raw_error) if error else None,
520
520
  "request_id": span["id"],
@@ -523,7 +523,7 @@ def normalize_span(
523
523
  "content_opted_in": True,
524
524
  "request_json": request_json,
525
525
  "request_text": request_text,
526
- "response_text": response_text,
526
+ "response_text": capture_response_text(response_text),
527
527
  "tool_calls": tool_calls,
528
528
  "tool_names": _tool_names(tool_calls),
529
529
  "trace_id": span.get("root_span_id"),
@@ -536,7 +536,7 @@ def normalize_span(
536
536
  row["import_source"] = import_context.source
537
537
  row["import_source_scope"] = import_context.source_scope
538
538
  row["import_event_id"] = canonical_import_event_id(span.get("id"))
539
- return row
539
+ return capture_row(row)
540
540
 
541
541
 
542
542
  def _cleanup_temp_file(tmp_path: str) -> None:
@@ -11,6 +11,8 @@ import urllib.request
11
11
  from typing import Any, Callable
12
12
 
13
13
  from .. import __version__
14
+ from ..billing_evidence import reported_cost
15
+ from ..capture_contract import capture_response_text, capture_row
14
16
  from ..import_identity import ImportContext, canonical_import_event_id
15
17
 
16
18
  DEFAULT_LANGFUSE_HOST = "https://cloud.langfuse.com"
@@ -296,6 +298,19 @@ _REASONING_KEYS = (
296
298
  )
297
299
  _NON_BUCKET_MARKERS = ("priority",)
298
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
+
299
314
 
300
315
  def _is_int(value: Any) -> bool:
301
316
  return isinstance(value, int) and not isinstance(value, bool)
@@ -314,17 +329,26 @@ def _first_int(source: dict[str, Any], keys: tuple[str, ...]) -> int | None:
314
329
 
315
330
 
316
331
  def _total_with_buckets(
317
- 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, ...] = (),
318
337
  ) -> int | None:
319
- """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
+ """
320
343
  total_key, total = _first_int_key(usage_details, total_keys)
321
344
  if total_key is None:
322
345
  return None
346
+ skipped = {key.lower() for key in skip_keys}
323
347
  for key, value in usage_details.items():
324
348
  if key == total_key or not _is_int(value):
325
349
  continue
326
350
  lowered = key.lower()
327
- if marker not in lowered:
351
+ if marker not in lowered or lowered in skipped:
328
352
  continue
329
353
  if any(skip in lowered for skip in _NON_BUCKET_MARKERS):
330
354
  continue
@@ -332,8 +356,15 @@ def _total_with_buckets(
332
356
  return total
333
357
 
334
358
 
335
- def map_usage_details(usage_details: Any) -> dict[str, int | None]:
336
- """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
+ """
337
368
  if not isinstance(usage_details, dict):
338
369
  return {
339
370
  "input_tokens": None,
@@ -342,8 +373,16 @@ def map_usage_details(usage_details: Any) -> dict[str, int | None]:
342
373
  "cache_write_tokens": None,
343
374
  "reasoning_tokens": None,
344
375
  }
376
+ cache_exclusive = (
377
+ str(provider or "").strip().lower() in _CACHE_EXCLUSIVE_INPUT_PROVIDERS
378
+ )
345
379
  return {
346
- "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
+ ),
347
386
  "output_tokens": _total_with_buckets(usage_details, _OUTPUT_TOTAL_KEYS, "output"),
348
387
  "cache_read_tokens": _first_int(usage_details, _CACHE_READ_KEYS),
349
388
  "cache_write_tokens": _first_int(usage_details, _CACHE_WRITE_KEYS),
@@ -381,7 +420,8 @@ def normalize_observation(
381
420
  raw_status_message if error and isinstance(raw_status_message, str) else None
382
421
  )
383
422
 
384
- usage = map_usage_details(observation.get("usageDetails"))
423
+ provider = infer_provider(observation)
424
+ usage = map_usage_details(observation.get("usageDetails"), provider=provider)
385
425
  request_json, request_text = _map_content(observation.get("input"))
386
426
  response_text = _response_text(observation.get("output"))
387
427
 
@@ -392,7 +432,7 @@ def normalize_observation(
392
432
  "source": "langfuse",
393
433
  "sdk": "metergraphrelay",
394
434
  "sdk_version": __version__,
395
- "provider": infer_provider(observation),
435
+ "provider": provider,
396
436
  "model": model,
397
437
  "status": "error" if error else "success",
398
438
  "input_tokens": usage["input_tokens"],
@@ -401,6 +441,7 @@ def normalize_observation(
401
441
  "cache_write_tokens": usage["cache_write_tokens"],
402
442
  "reasoning_tokens": usage["reasoning_tokens"],
403
443
  "cost_usd": observation.get("totalCost"),
444
+ **reported_cost(observation.get("totalCost"), source="langfuse.observation.totalCost"),
404
445
  "error": error,
405
446
  "error_type": error_type,
406
447
  "request_id": observation["id"],
@@ -409,7 +450,7 @@ def normalize_observation(
409
450
  "content_opted_in": True,
410
451
  "request_json": request_json,
411
452
  "request_text": request_text,
412
- "response_text": response_text,
453
+ "response_text": capture_response_text(response_text),
413
454
  "trace_id": observation["traceId"],
414
455
  "span_id": observation["id"],
415
456
  "parent_span_id": observation.get("parentObservationId"),
@@ -423,7 +464,7 @@ def normalize_observation(
423
464
  row["import_source"] = import_context.source
424
465
  row["import_source_scope"] = import_context.source_scope
425
466
  row["import_event_id"] = canonical_import_event_id(observation.get("id"))
426
- return row
467
+ return capture_row(row)
427
468
 
428
469
 
429
470
  def _cleanup_temp_file(tmp_path: str) -> None:
@@ -39,6 +39,8 @@ 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
43
+ from ..capture_contract import capture_response_text, capture_row
42
44
  from ..import_identity import ImportContext, canonical_import_event_id
43
45
  from ..window import normalize_utc_designator
44
46
 
@@ -472,6 +474,7 @@ def normalize_run(
472
474
  "reasoning_tokens": usage["reasoning_tokens"],
473
475
  "latency_ms": _latency_ms(run.get("start_time"), run.get("end_time")),
474
476
  "cost_usd": _cost_usd(run),
477
+ **reported_cost(_cost_usd(run), source="langsmith.total_cost"),
475
478
  "error": error,
476
479
  "error_type": error_type,
477
480
  "request_id": run_id,
@@ -480,7 +483,7 @@ def normalize_run(
480
483
  "content_opted_in": True,
481
484
  "request_json": request_json,
482
485
  "request_text": request_text,
483
- "response_text": _response_text(run.get("outputs")),
486
+ "response_text": capture_response_text(_response_text(run.get("outputs"))),
484
487
  "trace_id": run.get("trace_id"),
485
488
  "span_id": run_id,
486
489
  "parent_span_id": run.get("parent_run_id"),
@@ -491,7 +494,7 @@ def normalize_run(
491
494
  row["import_source"] = import_context.source
492
495
  row["import_source_scope"] = import_context.source_scope
493
496
  row["import_event_id"] = canonical_import_event_id(run.get("id"))
494
- return row
497
+ return capture_row(row)
495
498
 
496
499
 
497
500
  def _cleanup_temp_file(tmp_path: str) -> None:
@@ -7,6 +7,55 @@ from itertools import islice
7
7
  from typing import Any, Iterable
8
8
 
9
9
  from .. import __version__
10
+ from ..capture_contract import capture_response_text, capture_row, capture_tool_calls
11
+
12
+
13
+ def _as_mapping(call: Any) -> Any:
14
+ """An SDK tool-call object as a plain mapping, however the client models it."""
15
+ if isinstance(call, dict):
16
+ return call
17
+ for attribute in ("model_dump", "to_dict", "dict"):
18
+ dump = getattr(call, attribute, None)
19
+ if callable(dump):
20
+ try:
21
+ return dump()
22
+ except TypeError:
23
+ continue
24
+ return getattr(call, "__dict__", {})
25
+
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
10
59
 
11
60
 
12
61
  def normalize_completion(
@@ -25,6 +74,7 @@ def normalize_completion(
25
74
  content_opted_in = include_content and content_fetch_error is None
26
75
  request_json: str | None = None
27
76
  response_text: str | None = None
77
+ tool_calls: list | None = None
28
78
  if content_opted_in:
29
79
  request_json = json.dumps(
30
80
  [{"role": m.role, "content": m.content} for m in message_list]
@@ -33,9 +83,16 @@ def normalize_completion(
33
83
  # the model's own reply — that lives on the completion object itself.
34
84
  choices = getattr(completion, "choices", None) or []
35
85
  if choices:
36
- response_text = getattr(choices[0].message, "content", None)
86
+ message = choices[0].message
87
+ response_text = getattr(message, "content", None)
88
+ # A tool-only reply carries its answer in the tool call and no text.
89
+ # Recording the empty text without the call would describe the turn
90
+ # as an empty response rather than the tool use it was.
91
+ tool_calls = capture_tool_calls(
92
+ [_as_mapping(call) for call in getattr(message, "tool_calls", None) or []]
93
+ )
37
94
 
38
- return {
95
+ return capture_row({
39
96
  "ts": ts,
40
97
  "provider": "openai",
41
98
  "model": completion.model,
@@ -43,6 +100,7 @@ def normalize_completion(
43
100
  "endpoint": "chat.completions",
44
101
  "input_tokens": getattr(usage, "prompt_tokens", None) if usage else None,
45
102
  "output_tokens": getattr(usage, "completion_tokens", None) if usage else None,
103
+ **_usage_detail(usage),
46
104
  "error": content_fetch_error is not None,
47
105
  "error_type": (
48
106
  type(content_fetch_error).__name__ if content_fetch_error else None
@@ -52,10 +110,13 @@ def normalize_completion(
52
110
  "route": route,
53
111
  "content_opted_in": content_opted_in,
54
112
  "request_json": request_json,
55
- "response_text": response_text,
113
+ "response_text": capture_response_text(
114
+ response_text, content_opted_in=content_opted_in
115
+ ),
116
+ "tool_calls": tool_calls,
56
117
  "sdk": "metergraphrelay",
57
118
  "sdk_version": __version__,
58
- }
119
+ })
59
120
 
60
121
 
61
122
  def pull_openai(
@@ -34,6 +34,7 @@ from datetime import datetime
34
34
  from typing import Any, Callable
35
35
 
36
36
  from .. import __version__
37
+ from ..capture_contract import capture_response_text, capture_row
37
38
  from ..window import normalize_utc_designator
38
39
  from ..import_identity import ImportContext, canonical_import_event_id
39
40
 
@@ -383,7 +384,7 @@ def normalize_span(
383
384
  "content_opted_in": True,
384
385
  "request_json": request_json,
385
386
  "request_text": request_text,
386
- "response_text": _response_text(attributes),
387
+ "response_text": capture_response_text(_response_text(attributes)),
387
388
  "tool_names": _tool_names(attributes),
388
389
  "trace_id": context.get("trace_id"),
389
390
  "span_id": span_id,
@@ -395,7 +396,7 @@ def normalize_span(
395
396
  row["import_source"] = import_context.source
396
397
  row["import_source_scope"] = import_context.source_scope
397
398
  row["import_event_id"] = canonical_import_event_id(span_id)
398
- return row
399
+ return capture_row(row)
399
400
 
400
401
 
401
402
  def _cleanup_temp_file(tmp_path: str) -> None:
@@ -5,11 +5,13 @@ 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__
12
- from ..capture_contract import capture_text, capture_tool_calls
13
+ from ..billing_evidence import reported_cost
14
+ from ..capture_contract import capture_row, capture_text, capture_tool_calls
13
15
 
14
16
 
15
17
  # Shared with the other sync providers; re-exported here for existing imports.
@@ -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,11 +502,12 @@ 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
307
509
  result["import_event_id"] = import_event_id # validated, stripped id
308
- return result
510
+ return capture_row(result)
309
511
 
310
512
 
311
513
  def convert_portkey_export(
@@ -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.0
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"
@@ -15,6 +15,7 @@ import pytest
15
15
 
16
16
  from metergraphrelay.capture_contract import (
17
17
  NATIVE_SEARCH_AUDIT_TYPES,
18
+ capture_response_text,
18
19
  capture_text,
19
20
  capture_tool_call,
20
21
  capture_tool_calls,
@@ -126,3 +127,33 @@ def test_no_surviving_tool_calls_is_none_not_an_empty_list():
126
127
  assert capture_tool_calls([{"type": "reasoning", "id": "rs-1"}]) is None
127
128
  assert capture_tool_calls([]) is None
128
129
  assert capture_tool_calls(None) is None
130
+
131
+
132
+ def test_a_recognised_tool_call_with_no_id_is_identified_by_position():
133
+ # Providers that log whatever an integration handed them sometimes omit the
134
+ # id. Dropping the call would describe a tool turn as text alone.
135
+ calls = capture_tool_calls(
136
+ [
137
+ {"type": "text", "text": "ignored"},
138
+ {"type": "tool_use", "name": "search", "input": {}},
139
+ ]
140
+ )
141
+
142
+ assert calls == [{"call_id": "tool-1", "name": "search", "arguments": "{}"}]
143
+
144
+
145
+ def test_a_tool_call_with_no_name_is_dropped_even_with_a_position():
146
+ assert capture_tool_calls([{"type": "tool_use", "id": "t-1"}]) is None
147
+
148
+
149
+ def test_capture_response_text_spells_a_text_less_reply_as_empty():
150
+ assert capture_response_text(None) == ""
151
+ assert capture_response_text("hi") == "hi"
152
+ assert capture_response_text({"a": 1}) == '{"a": 1}'
153
+
154
+
155
+ def test_capture_response_text_leaves_an_opted_out_row_without_content():
156
+ # The pipeline reads None here as the content opt-out, which is the one
157
+ # place None is correct.
158
+ assert capture_response_text(None, content_opted_in=False) is None
159
+ assert capture_response_text("hi", content_opted_in=False) is None
@@ -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.0"
File without changes