metergraph 0.6.2__tar.gz → 0.6.4__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 (24) hide show
  1. {metergraph-0.6.2/src/metergraph.egg-info → metergraph-0.6.4}/PKG-INFO +1 -1
  2. {metergraph-0.6.2 → metergraph-0.6.4}/pyproject.toml +1 -1
  3. {metergraph-0.6.2 → metergraph-0.6.4}/src/metergraph/_capture.py +124 -32
  4. {metergraph-0.6.2 → metergraph-0.6.4}/src/metergraph/_version.py +1 -1
  5. {metergraph-0.6.2 → metergraph-0.6.4/src/metergraph.egg-info}/PKG-INFO +1 -1
  6. {metergraph-0.6.2 → metergraph-0.6.4}/MANIFEST.in +0 -0
  7. {metergraph-0.6.2 → metergraph-0.6.4}/README.md +0 -0
  8. {metergraph-0.6.2 → metergraph-0.6.4}/setup.cfg +0 -0
  9. {metergraph-0.6.2 → metergraph-0.6.4}/src/metergraph/__init__.py +0 -0
  10. {metergraph-0.6.2 → metergraph-0.6.4}/src/metergraph/_batch_first.py +0 -0
  11. {metergraph-0.6.2 → metergraph-0.6.4}/src/metergraph/_config.py +0 -0
  12. {metergraph-0.6.2 → metergraph-0.6.4}/src/metergraph/_context.py +0 -0
  13. {metergraph-0.6.2 → metergraph-0.6.4}/src/metergraph/_failure_log.py +0 -0
  14. {metergraph-0.6.2 → metergraph-0.6.4}/src/metergraph/_provider_batch.py +0 -0
  15. {metergraph-0.6.2 → metergraph-0.6.4}/src/metergraph/_repo_config.py +0 -0
  16. {metergraph-0.6.2 → metergraph-0.6.4}/src/metergraph/_session.py +0 -0
  17. {metergraph-0.6.2 → metergraph-0.6.4}/src/metergraph/_template.py +0 -0
  18. {metergraph-0.6.2 → metergraph-0.6.4}/src/metergraph/_track.py +0 -0
  19. {metergraph-0.6.2 → metergraph-0.6.4}/src/metergraph/_transport.py +0 -0
  20. {metergraph-0.6.2 → metergraph-0.6.4}/src/metergraph/opentelemetry.py +0 -0
  21. {metergraph-0.6.2 → metergraph-0.6.4}/src/metergraph.egg-info/SOURCES.txt +0 -0
  22. {metergraph-0.6.2 → metergraph-0.6.4}/src/metergraph.egg-info/dependency_links.txt +0 -0
  23. {metergraph-0.6.2 → metergraph-0.6.4}/src/metergraph.egg-info/requires.txt +0 -0
  24. {metergraph-0.6.2 → metergraph-0.6.4}/src/metergraph.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: metergraph
3
- Version: 0.6.2
3
+ Version: 0.6.4
4
4
  Summary: Fire-and-forget LLM spend capture for Metergraph
5
5
  Author: Pioneer Square Labs
6
6
  License-Expression: Apache-2.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "metergraph"
3
- version = "0.6.2"
3
+ version = "0.6.4"
4
4
  description = "Fire-and-forget LLM spend capture for Metergraph"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -858,14 +858,22 @@ class CallState:
858
858
 
859
859
  class _StreamState:
860
860
  def __init__(self, stream: Any, call: CallState) -> None:
861
+ self.manager = stream
861
862
  self.stream = stream
862
863
  self.call = call
863
864
  self.iterator = None
865
+ self.entered = False
864
866
  self.last = None
865
867
  self.parts: list[str] = []
866
868
  self.chunks: list[Any] = []
867
869
  self.ttft_ms: int | None = None
868
870
 
871
+ def use_entered_stream(self, stream: Any) -> None:
872
+ if stream is not None:
873
+ self.stream = stream
874
+ self.iterator = None
875
+ self.entered = True
876
+
869
877
  def chunk(self, value: Any) -> Any:
870
878
  self.last = value
871
879
  self.chunks.append(value)
@@ -876,6 +884,28 @@ class _StreamState:
876
884
  self.parts.append(text)
877
885
  return value
878
886
 
887
+ def safe_chunk(self, value: Any) -> Any:
888
+ """Record a provider chunk, but return the raw provider value if
889
+ MeterGraph's own normalization/bookkeeping fails. The caller passes the
890
+ chunk it already obtained from the provider, so a telemetry error here
891
+ never drops or corrupts iteration. Only MeterGraph's ``Exception`` is
892
+ swallowed; a cancellation propagating through ``chunk`` is not."""
893
+ try:
894
+ return self.chunk(value)
895
+ except Exception:
896
+ return value
897
+
898
+ def is_usage_only(self, value: Any) -> bool:
899
+ """Classify a usage-only chunk, failing open. A genuine usage-only chunk
900
+ is suppressed only when classification succeeds; if MeterGraph's own
901
+ classification raises on a pathological/proxied chunk, treat it as an
902
+ ordinary provider chunk so it is yielded, not dropped. Only MeterGraph's
903
+ ``Exception`` is swallowed here."""
904
+ try:
905
+ return _usage_only_chunk(value, self.call)
906
+ except Exception:
907
+ return False
908
+
879
909
  def finish(
880
910
  self, status: str | None = None, error: BaseException | None = None
881
911
  ) -> None:
@@ -925,6 +955,32 @@ class _StreamState:
925
955
  stream_chunks=self.chunks,
926
956
  )
927
957
 
958
+ def safe_finish(
959
+ self, status: str | None = None, error: BaseException | None = None
960
+ ) -> None:
961
+ """Shared fail-open finalization for every sync lifecycle path (entry
962
+ error, normal exhaustion, provider iteration error, context exit, and
963
+ close). MeterGraph's own telemetry ``Exception`` is swallowed so a
964
+ failed finish can never replace normal completion, the provider
965
+ iteration exception, or a close; the trace may be lost, but application
966
+ behavior is unchanged. Provider/application exceptions never reach
967
+ here."""
968
+ try:
969
+ self.finish(status=status, error=error)
970
+ except Exception:
971
+ pass
972
+
973
+ async def safe_finish_async(
974
+ self, status: str | None = None, error: BaseException | None = None
975
+ ) -> None:
976
+ """Async counterpart to :meth:`safe_finish`, shared by every async
977
+ lifecycle path (entry error, normal exhaustion, provider iteration
978
+ error, context exit, and aclose)."""
979
+ try:
980
+ await self.finish_async(status=status, error=error)
981
+ except Exception:
982
+ pass
983
+
928
984
 
929
985
  class SyncStream:
930
986
  def __init__(self, stream: Any, call: CallState) -> None:
@@ -943,29 +999,34 @@ class SyncStream:
943
999
  self._state.iterator = iter(self._state.stream)
944
1000
  while True:
945
1001
  try:
946
- value = self._state.chunk(next(self._state.iterator))
947
- if _usage_only_chunk(value, self._state.call):
948
- continue
949
- return value
1002
+ provider_chunk = next(self._state.iterator)
950
1003
  except StopIteration:
951
- self._state.finish()
1004
+ self._state.safe_finish()
952
1005
  raise
953
1006
  except BaseException as exc:
954
- self._state.finish(error=exc)
1007
+ self._state.safe_finish(error=exc)
955
1008
  raise
1009
+ value = self._state.safe_chunk(provider_chunk)
1010
+ if self._state.is_usage_only(value):
1011
+ continue
1012
+ return value
956
1013
 
957
1014
  def __enter__(self) -> "SyncStream":
958
- enter = getattr(self._state.stream, "__enter__", None)
1015
+ enter = getattr(self._state.manager, "__enter__", None)
959
1016
  if enter:
960
- enter()
1017
+ try:
1018
+ self._state.use_entered_stream(enter())
1019
+ except BaseException as exc:
1020
+ self._state.safe_finish(error=exc)
1021
+ raise
961
1022
  return self
962
1023
 
963
1024
  def __exit__(self, exc_type, exc, tb) -> Any:
964
1025
  if exc:
965
- self._state.finish(error=exc)
1026
+ self._state.safe_finish(error=exc)
966
1027
  elif not self._state.call.done:
967
- self._state.finish(status="abandoned")
968
- exit_fn = getattr(self._state.stream, "__exit__", None)
1028
+ self._state.safe_finish(status=None if self._state.entered else "abandoned")
1029
+ exit_fn = getattr(self._state.manager, "__exit__", None)
969
1030
  return exit_fn(exc_type, exc, tb) if exit_fn else None
970
1031
 
971
1032
  def close(self) -> None:
@@ -973,7 +1034,7 @@ class SyncStream:
973
1034
  if close:
974
1035
  close()
975
1036
  if not self._state.call.done:
976
- self._state.finish(status="abandoned")
1037
+ self._state.safe_finish(status="abandoned")
977
1038
 
978
1039
  def __del__(self) -> None:
979
1040
  try:
@@ -1000,29 +1061,36 @@ class AsyncStream:
1000
1061
  self._state.iterator = self._state.stream.__aiter__()
1001
1062
  while True:
1002
1063
  try:
1003
- value = self._state.chunk(await self._state.iterator.__anext__())
1004
- if _usage_only_chunk(value, self._state.call):
1005
- continue
1006
- return value
1064
+ provider_chunk = await self._state.iterator.__anext__()
1007
1065
  except StopAsyncIteration:
1008
- await self._state.finish_async()
1066
+ await self._state.safe_finish_async()
1009
1067
  raise
1010
1068
  except BaseException as exc:
1011
- await self._state.finish_async(error=exc)
1069
+ await self._state.safe_finish_async(error=exc)
1012
1070
  raise
1071
+ value = self._state.safe_chunk(provider_chunk)
1072
+ if self._state.is_usage_only(value):
1073
+ continue
1074
+ return value
1013
1075
 
1014
1076
  async def __aenter__(self) -> "AsyncStream":
1015
- enter = getattr(self._state.stream, "__aenter__", None)
1077
+ enter = getattr(self._state.manager, "__aenter__", None)
1016
1078
  if enter:
1017
- await enter()
1079
+ try:
1080
+ self._state.use_entered_stream(await enter())
1081
+ except BaseException as exc:
1082
+ await self._state.safe_finish_async(error=exc)
1083
+ raise
1018
1084
  return self
1019
1085
 
1020
1086
  async def __aexit__(self, exc_type, exc, tb) -> Any:
1021
1087
  if exc:
1022
- await self._state.finish_async(error=exc)
1088
+ await self._state.safe_finish_async(error=exc)
1023
1089
  elif not self._state.call.done:
1024
- await self._state.finish_async(status="abandoned")
1025
- exit_fn = getattr(self._state.stream, "__aexit__", None)
1090
+ await self._state.safe_finish_async(
1091
+ status=None if self._state.entered else "abandoned"
1092
+ )
1093
+ exit_fn = getattr(self._state.manager, "__aexit__", None)
1026
1094
  return await exit_fn(exc_type, exc, tb) if exit_fn else None
1027
1095
 
1028
1096
  async def aclose(self) -> None:
@@ -1030,7 +1098,7 @@ class AsyncStream:
1030
1098
  if close:
1031
1099
  await close()
1032
1100
  if not self._state.call.done:
1033
- await self._state.finish_async(status="abandoned")
1101
+ await self._state.safe_finish_async(status="abandoned")
1034
1102
 
1035
1103
  def __del__(self) -> None:
1036
1104
  try:
@@ -1341,13 +1409,19 @@ def _patch(
1341
1409
  args = (first, *args[1:])
1342
1410
  else:
1343
1411
  kwargs = {**kwargs, "stream_options": {"include_usage": True}}
1344
- request = _request(args, kwargs)
1345
- capture_provider = _gateway_provider(request) if gateway else provider
1346
- call = runtime.call_state(capture_provider, endpoint, request)
1412
+ try:
1413
+ request = _request(args, kwargs)
1414
+ capture_provider = _gateway_provider(request) if gateway else provider
1415
+ call = runtime.call_state(capture_provider, endpoint, request)
1416
+ except Exception:
1417
+ # Capture-state creation is telemetry-only. If it fails, invoke the
1418
+ # provider exactly once and return/await its native result with no
1419
+ # MeterGraph wrapping — the caller observes an uninstrumented call.
1420
+ return original(*args, **kwargs)
1347
1421
  try:
1348
1422
  result = original(*args, **kwargs)
1349
1423
  except BaseException as exc:
1350
- call.finish(error=exc)
1424
+ _safe_finish(call, error=exc)
1351
1425
  raise
1352
1426
 
1353
1427
  if inspect.isawaitable(result):
@@ -1356,7 +1430,7 @@ def _patch(
1356
1430
  try:
1357
1431
  resolved = await result
1358
1432
  except BaseException as exc:
1359
- call.finish(error=exc)
1433
+ _safe_finish(call, error=exc)
1360
1434
  raise
1361
1435
  return _finish_or_stream(resolved, call, endpoint, request)
1362
1436
 
@@ -1371,15 +1445,33 @@ def _patch(
1371
1445
  return True
1372
1446
 
1373
1447
 
1448
+ def _safe_finish(call: CallState, response: Any = None, **kwargs: Any) -> None:
1449
+ """Fail-open finalization for a non-streaming call. MeterGraph's own
1450
+ telemetry ``Exception`` is swallowed so a failed ``CallState.finish`` can
1451
+ never replace the provider result or the provider exception the caller must
1452
+ observe. Only telemetry runs here — the provider result/exception is passed
1453
+ in, never produced — so a cancellation is never wrongly swallowed."""
1454
+ try:
1455
+ call.finish(response, **kwargs)
1456
+ except Exception:
1457
+ pass
1458
+
1459
+
1374
1460
  def _finish_or_stream(
1375
1461
  result: Any, call: CallState, endpoint: str, request: Mapping[str, Any]
1376
1462
  ):
1377
1463
  is_stream = endpoint.endswith(".stream") or bool(request.get("stream"))
1378
- if is_stream and hasattr(result, "__aiter__"):
1464
+ if is_stream and (
1465
+ hasattr(result, "__aiter__")
1466
+ or (hasattr(result, "__aenter__") and hasattr(result, "__aexit__"))
1467
+ ):
1379
1468
  return AsyncStream(result, call)
1380
- if is_stream and hasattr(result, "__iter__"):
1469
+ if is_stream and (
1470
+ hasattr(result, "__iter__")
1471
+ or (hasattr(result, "__enter__") and hasattr(result, "__exit__"))
1472
+ ):
1381
1473
  return SyncStream(result, call)
1382
- call.finish(result)
1474
+ _safe_finish(call, result)
1383
1475
  return result
1384
1476
 
1385
1477
 
@@ -8,4 +8,4 @@ import importlib.metadata
8
8
  try:
9
9
  SDK_VERSION = importlib.metadata.version("metergraph")
10
10
  except Exception:
11
- SDK_VERSION = "0.6.2"
11
+ SDK_VERSION = "0.6.4"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: metergraph
3
- Version: 0.6.2
3
+ Version: 0.6.4
4
4
  Summary: Fire-and-forget LLM spend capture for Metergraph
5
5
  Author: Pioneer Square Labs
6
6
  License-Expression: Apache-2.0
File without changes
File without changes
File without changes