metergraph 0.6.5__tar.gz → 0.6.7__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 (28) hide show
  1. {metergraph-0.6.5/src/metergraph.egg-info → metergraph-0.6.7}/PKG-INFO +9 -1
  2. {metergraph-0.6.5 → metergraph-0.6.7}/README.md +8 -0
  3. {metergraph-0.6.5 → metergraph-0.6.7}/pyproject.toml +1 -1
  4. {metergraph-0.6.5 → metergraph-0.6.7}/src/metergraph/_capture.py +17 -12
  5. {metergraph-0.6.5 → metergraph-0.6.7}/src/metergraph/_gateway.py +31 -1
  6. metergraph-0.6.7/src/metergraph/_genai_attrs.py +583 -0
  7. metergraph-0.6.7/src/metergraph/_template.py +149 -0
  8. {metergraph-0.6.5 → metergraph-0.6.7}/src/metergraph/_version.py +1 -1
  9. metergraph-0.6.7/src/metergraph/opentelemetry.py +213 -0
  10. {metergraph-0.6.5 → metergraph-0.6.7/src/metergraph.egg-info}/PKG-INFO +9 -1
  11. {metergraph-0.6.5 → metergraph-0.6.7}/src/metergraph.egg-info/SOURCES.txt +1 -0
  12. metergraph-0.6.5/src/metergraph/_template.py +0 -74
  13. metergraph-0.6.5/src/metergraph/opentelemetry.py +0 -221
  14. {metergraph-0.6.5 → metergraph-0.6.7}/MANIFEST.in +0 -0
  15. {metergraph-0.6.5 → metergraph-0.6.7}/setup.cfg +0 -0
  16. {metergraph-0.6.5 → metergraph-0.6.7}/src/metergraph/__init__.py +0 -0
  17. {metergraph-0.6.5 → metergraph-0.6.7}/src/metergraph/_batch_first.py +0 -0
  18. {metergraph-0.6.5 → metergraph-0.6.7}/src/metergraph/_config.py +0 -0
  19. {metergraph-0.6.5 → metergraph-0.6.7}/src/metergraph/_context.py +0 -0
  20. {metergraph-0.6.5 → metergraph-0.6.7}/src/metergraph/_failure_log.py +0 -0
  21. {metergraph-0.6.5 → metergraph-0.6.7}/src/metergraph/_provider_batch.py +0 -0
  22. {metergraph-0.6.5 → metergraph-0.6.7}/src/metergraph/_repo_config.py +0 -0
  23. {metergraph-0.6.5 → metergraph-0.6.7}/src/metergraph/_session.py +0 -0
  24. {metergraph-0.6.5 → metergraph-0.6.7}/src/metergraph/_track.py +0 -0
  25. {metergraph-0.6.5 → metergraph-0.6.7}/src/metergraph/_transport.py +0 -0
  26. {metergraph-0.6.5 → metergraph-0.6.7}/src/metergraph.egg-info/dependency_links.txt +0 -0
  27. {metergraph-0.6.5 → metergraph-0.6.7}/src/metergraph.egg-info/requires.txt +0 -0
  28. {metergraph-0.6.5 → metergraph-0.6.7}/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.5
3
+ Version: 0.6.7
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,6 +137,14 @@ 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
+ On a tracer provider shared with other instrumentation,
141
+ `MetergraphGenAIExporter(include_scopes=[...], exclude_scopes=[...])` filters on
142
+ `span.instrumentation_scope.name` (exclude wins; with `include_scopes` set, only
143
+ the listed scopes pass), so one call is never captured twice. The public
144
+ `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
146
+ shows nothing.
147
+
140
148
  Configuration:
141
149
 
142
150
  - `METERGRAPH_APP_TOKEN` — required bearer token
@@ -112,6 +112,14 @@ 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
+ On a tracer provider shared with other instrumentation,
116
+ `MetergraphGenAIExporter(include_scopes=[...], exclude_scopes=[...])` filters on
117
+ `span.instrumentation_scope.name` (exclude wins; with `include_scopes` set, only
118
+ the listed scopes pass), so one call is never captured twice. The public
119
+ `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
121
+ shows nothing.
122
+
115
123
  Configuration:
116
124
 
117
125
  - `METERGRAPH_APP_TOKEN` — required bearer token
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "metergraph"
3
- version = "0.6.5"
3
+ version = "0.6.7"
4
4
  description = "Fire-and-forget LLM spend capture for Metergraph"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -20,7 +20,7 @@ from urllib.parse import urlsplit
20
20
 
21
21
  from ._context import CaptureContext, snapshot
22
22
  from ._gateway import detect_gateway, gateway_evidence, resolve_gateway
23
- from ._template import scrub, template_hash
23
+ from ._template import json_value, scrub_request, template_hash
24
24
  from ._version import SDK_VERSION
25
25
 
26
26
 
@@ -330,29 +330,34 @@ def _request_id(response: Any) -> str | None:
330
330
  def _response_content(response: Any, aggregate_text: str | None = None) -> Any:
331
331
  if aggregate_text is not None:
332
332
  return aggregate_text
333
- direct = _get(response, "output_text") or _get(response, "text")
334
- if direct is not None:
335
- return scrub(direct)
333
+ # Only a non-empty string is reply text. On an OpenAI Responses object
334
+ # `text` is the text *config*, and a function-call-only reply lives in
335
+ # `output`.
336
+ direct = _get(response, "output_text")
337
+ if not isinstance(direct, str) or not direct:
338
+ direct = _get(response, "text")
339
+ if isinstance(direct, str):
340
+ return direct
336
341
  choice = _first(_get(response, "choices"))
337
342
  message = _get(choice, "message")
338
343
  content = _get(message, "content")
339
344
  if content is not None:
340
- return scrub(content)
345
+ return json_value(content)
341
346
  parsed = _get(message, "parsed")
342
347
  if parsed is not None:
343
- return scrub(parsed)
348
+ return json_value(parsed)
344
349
  normalized_text = _response_text(response)
345
350
  if normalized_text is not None:
346
351
  return normalized_text
347
352
  blocks = _get(response, "content")
348
353
  if blocks is not None:
349
- return scrub(blocks)
354
+ return json_value(blocks)
350
355
  outputs = _get(response, "output")
351
356
  if outputs is not None:
352
- return scrub(outputs)
357
+ return json_value(outputs)
353
358
  candidates = _get(response, "candidates")
354
359
  if candidates is not None:
355
- return scrub(candidates)
360
+ return json_value(candidates)
356
361
  return None
357
362
 
358
363
 
@@ -402,7 +407,7 @@ def _tool_argument(value: Any) -> Any:
402
407
  return json.loads(value)
403
408
  except json.JSONDecodeError:
404
409
  return value
405
- return scrub(value)
410
+ return json_value(value)
406
411
 
407
412
 
408
413
  def _tool_policies(request: Mapping[str, Any]) -> dict[str, str]:
@@ -591,7 +596,7 @@ def _tool_events(
591
596
  block = _get(chunk, "content_block")
592
597
  if _get(block, "type") == "tool_use":
593
598
  key = str(_get(chunk, "index", len(anthropic_deltas)))
594
- initial_input = scrub(_get(block, "input", {}))
599
+ initial_input = json_value(_get(block, "input", {}))
595
600
  anthropic_deltas[key] = {
596
601
  "id": str(_get(block, "id") or key),
597
602
  "name": str(_get(block, "name") or ""),
@@ -769,7 +774,7 @@ class CallState:
769
774
  if self.context.capture_text is not None
770
775
  else self.runtime.options.capture_text
771
776
  )
772
- request_clean = scrub(self.request)
777
+ request_clean = scrub_request(self.request)
773
778
  request_json, request_truncated = self.runtime._text(
774
779
  json.dumps(request_clean, separators=(",", ":"), default=repr),
775
780
  "request",
@@ -43,6 +43,15 @@ OPENROUTER = GatewayContract(
43
43
  # Registry of supported gateways, keyed by canonical name.
44
44
  GATEWAYS: dict[str, GatewayContract] = {OPENROUTER.name: OPENROUTER}
45
45
 
46
+ # Evidence-only cost provenance: unlike a GatewayContract these assert no
47
+ # gateway identity, qualify every endpoint, and are kept out of GATEWAYS so
48
+ # detect_gateway() and resolve_gateway() never accept them -- an explicit
49
+ # wrap(..., gateway=...) override names a gateway contract only.
50
+ LANGFUSE_SPAN_COST = "langfuse.observation.cost_details.total"
51
+ OPENINFERENCE_SPAN_COST = "openinference.llm.cost.total"
52
+
53
+ EVIDENCE_COST_SOURCES = frozenset({LANGFUSE_SPAN_COST, OPENINFERENCE_SPAN_COST})
54
+
46
55
  # Model/provider identifiers are bounded to the same practical limit the rest of
47
56
  # the SDK applies to those strings.
48
57
  _MAX_IDENTIFIER_LEN = 512
@@ -115,6 +124,22 @@ def _billing_amount(value: Any) -> float | None:
115
124
  return number
116
125
 
117
126
 
127
+ def evidence_for_mapped_cost(cost: Any, cost_source: Any) -> dict[str, Any]:
128
+ """Build capture-row cost evidence from a mapped vendor-reported cost.
129
+
130
+ ``cost_source`` must be one of the SDK-fixed provenance strings in
131
+ EVIDENCE_COST_SOURCES (e.g. a ``MappedCall.cost_source``); anything else
132
+ emits nothing, so provenance stays SDK-controlled. Malformed amounts are
133
+ omitted. The result never contains a ``gateway`` key.
134
+ """
135
+ if cost_source not in EVIDENCE_COST_SOURCES:
136
+ return {}
137
+ amount = _billing_amount(cost)
138
+ if amount is None:
139
+ return {}
140
+ return {"reported_cost_usd": amount, "reported_cost_source": cost_source}
141
+
142
+
118
143
  def gateway_evidence(
119
144
  gateway: str | None, endpoint: str, response: Any
120
145
  ) -> dict[str, Any]:
@@ -125,12 +150,17 @@ def gateway_evidence(
125
150
  emitted only on the gateway's qualified endpoints. A provenance source
126
151
  appears only alongside the value it describes. The served provider is never
127
152
  inferred.
153
+
154
+ ``gateway`` may also name an evidence-only contract by its SDK-fixed
155
+ provenance string (see EVIDENCE_COST_SOURCES).
128
156
  """
129
157
  if gateway is None:
130
158
  return {}
131
159
  contract = GATEWAYS.get(gateway)
132
160
  if contract is None:
133
- return {}
161
+ # An evidence-only provenance string: the observed scalar is read
162
+ # from the response's "cost" field.
163
+ return evidence_for_mapped_cost(_get(response, "cost"), gateway)
134
164
 
135
165
  evidence: dict[str, Any] = {"gateway": contract.name}
136
166