metergraph 0.6.7__tar.gz → 0.6.9__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 (27) hide show
  1. {metergraph-0.6.7/src/metergraph.egg-info → metergraph-0.6.9}/PKG-INFO +9 -2
  2. {metergraph-0.6.7 → metergraph-0.6.9}/README.md +8 -1
  3. {metergraph-0.6.7 → metergraph-0.6.9}/pyproject.toml +1 -1
  4. {metergraph-0.6.7 → metergraph-0.6.9}/src/metergraph/_capture.py +270 -4
  5. {metergraph-0.6.7 → metergraph-0.6.9}/src/metergraph/_context.py +3 -0
  6. {metergraph-0.6.7 → metergraph-0.6.9}/src/metergraph/_genai_attrs.py +225 -28
  7. {metergraph-0.6.7 → metergraph-0.6.9}/src/metergraph/_template.py +75 -0
  8. metergraph-0.6.9/src/metergraph/_tool_definitions.py +481 -0
  9. {metergraph-0.6.7 → metergraph-0.6.9}/src/metergraph/_version.py +1 -1
  10. {metergraph-0.6.7 → metergraph-0.6.9}/src/metergraph/opentelemetry.py +13 -1
  11. {metergraph-0.6.7 → metergraph-0.6.9/src/metergraph.egg-info}/PKG-INFO +9 -2
  12. {metergraph-0.6.7 → metergraph-0.6.9}/src/metergraph.egg-info/SOURCES.txt +1 -0
  13. {metergraph-0.6.7 → metergraph-0.6.9}/MANIFEST.in +0 -0
  14. {metergraph-0.6.7 → metergraph-0.6.9}/setup.cfg +0 -0
  15. {metergraph-0.6.7 → metergraph-0.6.9}/src/metergraph/__init__.py +0 -0
  16. {metergraph-0.6.7 → metergraph-0.6.9}/src/metergraph/_batch_first.py +0 -0
  17. {metergraph-0.6.7 → metergraph-0.6.9}/src/metergraph/_config.py +0 -0
  18. {metergraph-0.6.7 → metergraph-0.6.9}/src/metergraph/_failure_log.py +0 -0
  19. {metergraph-0.6.7 → metergraph-0.6.9}/src/metergraph/_gateway.py +0 -0
  20. {metergraph-0.6.7 → metergraph-0.6.9}/src/metergraph/_provider_batch.py +0 -0
  21. {metergraph-0.6.7 → metergraph-0.6.9}/src/metergraph/_repo_config.py +0 -0
  22. {metergraph-0.6.7 → metergraph-0.6.9}/src/metergraph/_session.py +0 -0
  23. {metergraph-0.6.7 → metergraph-0.6.9}/src/metergraph/_track.py +0 -0
  24. {metergraph-0.6.7 → metergraph-0.6.9}/src/metergraph/_transport.py +0 -0
  25. {metergraph-0.6.7 → metergraph-0.6.9}/src/metergraph.egg-info/dependency_links.txt +0 -0
  26. {metergraph-0.6.7 → metergraph-0.6.9}/src/metergraph.egg-info/requires.txt +0 -0
  27. {metergraph-0.6.7 → metergraph-0.6.9}/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.7
3
+ Version: 0.6.9
4
4
  Summary: Fire-and-forget LLM spend capture for Metergraph
5
5
  Author: Pioneer Square Labs
6
6
  License-Expression: Apache-2.0
@@ -137,12 +137,19 @@ part types retain their model, usage, timing, and status metadata, but those
137
137
  parts are not replayable yet. See the runnable
138
138
  [`python-litellm-otel` example](../examples/python-litellm-otel/).
139
139
 
140
+ The exporter reads the OpenTelemetry `gen_ai.*` conventions, OpenInference
141
+ (Arize Phoenix), Langfuse and LangSmith spans. Dialects that label span kind
142
+ are honoured: a Langfuse observation that is not a `generation`, an
143
+ OpenInference span that is not `LLM`, and a LangSmith run whose
144
+ `langsmith.span.kind` is not `llm` are skipped as `"ineligible-kind"` rather
145
+ than counted as calls.
146
+
140
147
  On a tracer provider shared with other instrumentation,
141
148
  `MetergraphGenAIExporter(include_scopes=[...], exclude_scopes=[...])` filters on
142
149
  `span.instrumentation_scope.name` (exclude wins; with `include_scopes` set, only
143
150
  the listed scopes pass), so one call is never captured twice. The public
144
151
  `exporter.skipped` dict counts spans that produced no row, keyed by reason
145
- (`"scope"`, `"not-genai"`, `"no-model"`) — the first thing to check when a tee
152
+ (`"scope"`, `"not-genai"`, `"ineligible-kind"`, `"no-model"`). The first thing to check when a tee
146
153
  shows nothing.
147
154
 
148
155
  Configuration:
@@ -112,12 +112,19 @@ part types retain their model, usage, timing, and status metadata, but those
112
112
  parts are not replayable yet. See the runnable
113
113
  [`python-litellm-otel` example](../examples/python-litellm-otel/).
114
114
 
115
+ The exporter reads the OpenTelemetry `gen_ai.*` conventions, OpenInference
116
+ (Arize Phoenix), Langfuse and LangSmith spans. Dialects that label span kind
117
+ are honoured: a Langfuse observation that is not a `generation`, an
118
+ OpenInference span that is not `LLM`, and a LangSmith run whose
119
+ `langsmith.span.kind` is not `llm` are skipped as `"ineligible-kind"` rather
120
+ than counted as calls.
121
+
115
122
  On a tracer provider shared with other instrumentation,
116
123
  `MetergraphGenAIExporter(include_scopes=[...], exclude_scopes=[...])` filters on
117
124
  `span.instrumentation_scope.name` (exclude wins; with `include_scopes` set, only
118
125
  the listed scopes pass), so one call is never captured twice. The public
119
126
  `exporter.skipped` dict counts spans that produced no row, keyed by reason
120
- (`"scope"`, `"not-genai"`, `"no-model"`) — the first thing to check when a tee
127
+ (`"scope"`, `"not-genai"`, `"ineligible-kind"`, `"no-model"`). The first thing to check when a tee
121
128
  shows nothing.
122
129
 
123
130
  Configuration:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "metergraph"
3
- version = "0.6.7"
3
+ version = "0.6.9"
4
4
  description = "Fire-and-forget LLM spend capture for Metergraph"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -18,9 +18,17 @@ from pathlib import Path
18
18
  from typing import Any, Callable, Mapping
19
19
  from urllib.parse import urlsplit
20
20
 
21
+ from . import _tool_definitions
21
22
  from ._context import CaptureContext, snapshot
22
23
  from ._gateway import detect_gateway, gateway_evidence, resolve_gateway
23
- from ._template import json_value, scrub_request, template_hash
24
+ from ._template import (
25
+ Unrepresentable,
26
+ json_equal,
27
+ json_value,
28
+ json_value_strict,
29
+ scrub_request,
30
+ template_hash,
31
+ )
24
32
  from ._version import SDK_VERSION
25
33
 
26
34
 
@@ -361,6 +369,174 @@ def _response_content(response: Any, aggregate_text: str | None = None) -> Any:
361
369
  return None
362
370
 
363
371
 
372
+ # Restrict tool-only detection to direct Anthropic response shapes.
373
+ _ANTHROPIC_ENDPOINTS = frozenset({"messages", "messages.stream"})
374
+ # Extra block fields would be lost when represented only by the canonical event.
375
+ _TOOL_BLOCK_KEYS = frozenset({"type", "id", "name", "input", "caller"})
376
+
377
+
378
+ def _block_mapping(block: Any) -> Mapping[str, Any] | None:
379
+ """Return a response block as lossless JSON data."""
380
+ try:
381
+ converted = json_value_strict(block)
382
+ except Unrepresentable:
383
+ return None
384
+ return converted if isinstance(converted, Mapping) else None
385
+
386
+
387
+ def _representable_tool_block(block: Any) -> Mapping[str, Any] | None:
388
+ mapping = _block_mapping(block)
389
+ if mapping is None or mapping.get("type") != "tool_use":
390
+ return None
391
+ if set(mapping) - _TOOL_BLOCK_KEYS:
392
+ return None
393
+ caller = mapping.get("caller")
394
+ if caller is not None:
395
+ if not isinstance(caller, Mapping):
396
+ return None
397
+ declared = {key: value for key, value in caller.items() if value is not None}
398
+ if declared != {"type": "direct"}:
399
+ return None
400
+ return mapping
401
+
402
+
403
+ def _matched_events(
404
+ blocks: list[Mapping[str, Any]],
405
+ events: list[dict] | None,
406
+ *,
407
+ compare_arguments: bool = True,
408
+ ) -> bool:
409
+ """Check that every reply block has one equivalent event."""
410
+ ids = [block.get("id") for block in blocks]
411
+ if any(not isinstance(value, str) or not value for value in ids):
412
+ return False
413
+ if len(set(ids)) != len(ids):
414
+ return False
415
+ for block in blocks:
416
+ matches = [
417
+ event for event in (events or []) if event.get("call_id") == block["id"]
418
+ ]
419
+ if len(matches) != 1:
420
+ return False
421
+ event = matches[0]
422
+ if event.get("name") != block.get("name"):
423
+ return False
424
+ # Streamed arguments are validated after delta accumulation.
425
+ if compare_arguments and not json_equal(
426
+ event.get("arguments"), _tool_argument(block.get("input"))
427
+ ):
428
+ return False
429
+ return True
430
+
431
+
432
+ def _arguments_equal(accumulated: str, event_arguments: Any) -> bool:
433
+ try:
434
+ expected = json.loads(accumulated) if accumulated.strip() else {}
435
+ actual = event_arguments
436
+ if isinstance(actual, str):
437
+ actual = json.loads(actual) if actual.strip() else {}
438
+ elif actual is None:
439
+ actual = {}
440
+ except (json.JSONDecodeError, TypeError):
441
+ return False
442
+ return json_equal(expected, actual)
443
+
444
+
445
+ def _stream_tool_only(chunks: list[Any], events: list[dict] | None) -> bool:
446
+ """Check whether a completed Anthropic stream contains only tool calls."""
447
+ started: dict[str, Mapping[str, Any]] = {}
448
+ arguments: dict[str, str] = {}
449
+ stopped: set[str] = set()
450
+ stop_reasons: list[str] = []
451
+ saw_message_stop = False
452
+ for chunk in chunks:
453
+ if _chunk_text(chunk):
454
+ return False
455
+ kind = _get(chunk, "type")
456
+ delta = _get(chunk, "delta")
457
+ if isinstance(delta, str) and "reasoning" in str(kind):
458
+ return False
459
+ if _get(delta, "thinking") or _get(delta, "reasoning"):
460
+ return False
461
+ if kind == "content_block_start":
462
+ mapping = _representable_tool_block(_get(chunk, "content_block"))
463
+ if mapping is None:
464
+ return False
465
+ key = str(_get(chunk, "index", len(started)))
466
+ started[key] = mapping
467
+ initial = mapping.get("input")
468
+ arguments[key] = (
469
+ ""
470
+ if initial in (None, {})
471
+ else json.dumps(initial, separators=(",", ":"))
472
+ )
473
+ elif kind == "content_block_delta":
474
+ if _get(delta, "type") != "input_json_delta":
475
+ return False
476
+ key = str(_get(chunk, "index", "0"))
477
+ arguments[key] = arguments.get(key, "") + str(
478
+ _get(delta, "partial_json") or ""
479
+ )
480
+ elif kind == "content_block_stop":
481
+ stopped.add(str(_get(chunk, "index", "0")))
482
+ elif kind == "message_delta":
483
+ reason = _get(delta, "stop_reason")
484
+ if reason is not None:
485
+ stop_reasons.append(str(reason))
486
+ elif kind == "message_stop":
487
+ saw_message_stop = True
488
+ if not started or not saw_message_stop:
489
+ return False
490
+ if set(started) - stopped:
491
+ return False
492
+ if any(reason != "tool_use" for reason in stop_reasons):
493
+ return False
494
+ blocks = list(started.values())
495
+ if not _matched_events(blocks, events, compare_arguments=False):
496
+ return False
497
+ for key, mapping in started.items():
498
+ matches = [
499
+ event for event in (events or []) if event.get("call_id") == mapping.get("id")
500
+ ]
501
+ if len(matches) != 1 or not _arguments_equal(
502
+ arguments.get(key, ""), matches[0].get("arguments")
503
+ ):
504
+ return False
505
+ return True
506
+
507
+
508
+ def _anthropic_tool_only(
509
+ response: Any,
510
+ *,
511
+ provider: str | None,
512
+ endpoint: str | None,
513
+ events: list[dict] | None,
514
+ aggregate_text: str | None,
515
+ stream_chunks: list[Any] | None,
516
+ completed: bool,
517
+ ) -> bool:
518
+ """Check for a losslessly represented Anthropic tool-only reply."""
519
+ if not completed or aggregate_text is not None:
520
+ return False
521
+ if provider != "anthropic" or endpoint not in _ANTHROPIC_ENDPOINTS:
522
+ return False
523
+ if not events:
524
+ return False
525
+ blocks = _get(response, "content")
526
+ if isinstance(blocks, list):
527
+ if not blocks:
528
+ return False
529
+ # Other stop reasons may indicate an unfinished tool call.
530
+ reason = _get(response, "stop_reason")
531
+ if reason is not None and str(reason) != "tool_use":
532
+ return False
533
+ mapped = [_representable_tool_block(block) for block in blocks]
534
+ if any(block is None for block in mapped):
535
+ return False
536
+ return _matched_events([block for block in mapped if block is not None], events)
537
+ return _stream_tool_only(list(stream_chunks or []), events)
538
+
539
+
364
540
  def _response_envelope(
365
541
  response: Any,
366
542
  *,
@@ -368,10 +544,28 @@ def _response_envelope(
368
544
  tool_calls: list[dict] | None,
369
545
  error: BaseException | None,
370
546
  status: str,
547
+ provider: str | None = None,
548
+ endpoint: str | None = None,
549
+ events: list[dict] | None = None,
550
+ stream_chunks: list[Any] | None = None,
551
+ completed: bool = False,
371
552
  ) -> dict[str, Any]:
553
+ tool_only = False
554
+ try:
555
+ tool_only = _anthropic_tool_only(
556
+ response,
557
+ provider=provider,
558
+ endpoint=endpoint,
559
+ events=events,
560
+ aggregate_text=aggregate_text,
561
+ stream_chunks=stream_chunks,
562
+ completed=completed,
563
+ )
564
+ except Exception:
565
+ tool_only = False
372
566
  envelope: dict[str, Any] = {
373
567
  "role": "assistant",
374
- "content": _response_content(response, aggregate_text),
568
+ "content": None if tool_only else _response_content(response, aggregate_text),
375
569
  "tool_calls": tool_calls or [],
376
570
  "finish_reason": _stop_reason(response),
377
571
  "request_id": _request_id(response),
@@ -385,7 +579,12 @@ def _response_envelope(
385
579
  "type": type(error).__name__,
386
580
  "message": str(error),
387
581
  }
388
- return {key: value for key, value in envelope.items() if value is not None}
582
+ # Explicit null distinguishes tool-only output from missing content data.
583
+ return {
584
+ key: value
585
+ for key, value in envelope.items()
586
+ if value is not None or (tool_only and key == "content")
587
+ }
389
588
 
390
589
 
391
590
  def _tool_names(request: Mapping[str, Any]) -> list[dict[str, str]] | None:
@@ -726,6 +925,9 @@ class Runtime:
726
925
  value = self.options.redact(value, kind)
727
926
  except Exception:
728
927
  return "<redaction-failed>", False
928
+ # Invalid hook output must not drop the capture row.
929
+ if not isinstance(value, str):
930
+ return "<redaction-failed>", False
729
931
  raw = value.encode()
730
932
  if len(raw) <= self.options.text_max_bytes:
731
933
  return value, False
@@ -735,6 +937,46 @@ class Runtime:
735
937
  )
736
938
  return clipped + marker, True
737
939
 
940
+ def _tool_definitions(
941
+ self, request: Mapping[str, Any], provider: str, *, enabled: bool
942
+ ) -> tuple[dict[str, Any] | None, bool]:
943
+ """Return redacted declarations and whether the size cap dropped them."""
944
+ if not enabled:
945
+ return None, False
946
+ # Optional declaration telemetry must not affect the provider call or row.
947
+ try:
948
+ return self._build_tool_definitions(request, provider)
949
+ except Exception:
950
+ return None, False
951
+
952
+ def _build_tool_definitions(
953
+ self, request: Mapping[str, Any], provider: str
954
+ ) -> tuple[dict[str, Any] | None, bool]:
955
+ read = _tool_definitions.declarations(request, provider)
956
+ if read is None:
957
+ return None, False
958
+ records, scope = read
959
+ encoded = json.dumps(records, ensure_ascii=False, separators=(",", ":"))
960
+ fidelity = "verbatim"
961
+ if self.options.redact:
962
+ redacted = self.options.redact(encoded, "request")
963
+ if not isinstance(redacted, str):
964
+ return None, False
965
+ try:
966
+ parsed = json.loads(redacted)
967
+ except (TypeError, ValueError, RecursionError):
968
+ return None, False
969
+ if not _tool_definitions.valid_declarations(parsed):
970
+ return None, False
971
+ if not json_equal(parsed, records):
972
+ fidelity = "filtered"
973
+ records = parsed
974
+ envelope = _tool_definitions.envelope(records, scope, fidelity)
975
+ serialized = json.dumps(envelope, ensure_ascii=False, separators=(",", ":"))
976
+ if len(serialized.encode()) > self.options.text_max_bytes:
977
+ return None, True
978
+ return envelope, False
979
+
738
980
 
739
981
  @dataclass
740
982
  class CallState:
@@ -808,6 +1050,11 @@ class CallState:
808
1050
  if full_tool_calls
809
1051
  else None
810
1052
  )
1053
+ tool_definitions_value, tool_definitions_truncated = (
1054
+ self.runtime._tool_definitions(
1055
+ self.request, self.provider, enabled=capture_text
1056
+ )
1057
+ )
811
1058
  effective_status = status or (
812
1059
  "error" if error else _stop_reason(response) or "success"
813
1060
  )
@@ -825,6 +1072,16 @@ class CallState:
825
1072
  tool_calls=full_tool_calls if capture_text else None,
826
1073
  error=error,
827
1074
  status=effective_status,
1075
+ provider=self.provider,
1076
+ endpoint=self.endpoint,
1077
+ events=full_tool_calls,
1078
+ stream_chunks=stream_chunks,
1079
+ completed=(
1080
+ error is None
1081
+ and status not in {"error", "abandoned"}
1082
+ and status_code != "error"
1083
+ and finish_reason != "error"
1084
+ ),
828
1085
  ),
829
1086
  ensure_ascii=False,
830
1087
  separators=(",", ":"),
@@ -836,6 +1093,7 @@ class CallState:
836
1093
  row: dict[str, Any] = {
837
1094
  "ts": self.ts,
838
1095
  "route": self.context.route,
1096
+ "route_source": self.context.route_source if self.context.route else None,
839
1097
  "provider": self.provider,
840
1098
  "model": self.request.get("model"),
841
1099
  **_usage(response),
@@ -865,7 +1123,12 @@ class CallState:
865
1123
  "content_opted_in": capture_text,
866
1124
  "request_json": request_json,
867
1125
  "response_text": response_json,
868
- "text_truncated": request_truncated or response_truncated or tool_truncated,
1126
+ "text_truncated": (
1127
+ request_truncated
1128
+ or response_truncated
1129
+ or tool_truncated
1130
+ or tool_definitions_truncated
1131
+ ),
869
1132
  "stream": stream,
870
1133
  "ttft_ms": ttft_ms,
871
1134
  "func": self.func,
@@ -879,6 +1142,9 @@ class CallState:
879
1142
  "sdk_version": SDK_VERSION,
880
1143
  "runtime": f"{platform.python_implementation().lower()}-{platform.python_version()}",
881
1144
  }
1145
+ # Absence distinguishes tool-free calls from invalid declarations.
1146
+ if tool_definitions_value is not None:
1147
+ row["tool_definitions"] = tool_definitions_value
882
1148
  try:
883
1149
  self.runtime.writer.enqueue(row)
884
1150
  except Exception:
@@ -15,6 +15,8 @@ from typing import Any, Callable, Mapping
15
15
  @dataclass(frozen=True)
16
16
  class CaptureContext:
17
17
  route: str | None = None
18
+ # "explicit" when the developer named the route, "derived" when filled in.
19
+ route_source: str | None = None
18
20
  session_id: str | None = None
19
21
  tags: Mapping[str, str] = field(default_factory=dict)
20
22
  unit_name: str | None = None
@@ -153,6 +155,7 @@ class route:
153
155
  replace(
154
156
  current,
155
157
  route=self.name,
158
+ route_source="explicit",
156
159
  tags=merged,
157
160
  unit_name=self.unit if self.unit is not None else current.unit_name,
158
161
  unit_count=self.unit_count