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.
- {metergraph-0.6.5/src/metergraph.egg-info → metergraph-0.6.7}/PKG-INFO +9 -1
- {metergraph-0.6.5 → metergraph-0.6.7}/README.md +8 -0
- {metergraph-0.6.5 → metergraph-0.6.7}/pyproject.toml +1 -1
- {metergraph-0.6.5 → metergraph-0.6.7}/src/metergraph/_capture.py +17 -12
- {metergraph-0.6.5 → metergraph-0.6.7}/src/metergraph/_gateway.py +31 -1
- metergraph-0.6.7/src/metergraph/_genai_attrs.py +583 -0
- metergraph-0.6.7/src/metergraph/_template.py +149 -0
- {metergraph-0.6.5 → metergraph-0.6.7}/src/metergraph/_version.py +1 -1
- metergraph-0.6.7/src/metergraph/opentelemetry.py +213 -0
- {metergraph-0.6.5 → metergraph-0.6.7/src/metergraph.egg-info}/PKG-INFO +9 -1
- {metergraph-0.6.5 → metergraph-0.6.7}/src/metergraph.egg-info/SOURCES.txt +1 -0
- metergraph-0.6.5/src/metergraph/_template.py +0 -74
- metergraph-0.6.5/src/metergraph/opentelemetry.py +0 -221
- {metergraph-0.6.5 → metergraph-0.6.7}/MANIFEST.in +0 -0
- {metergraph-0.6.5 → metergraph-0.6.7}/setup.cfg +0 -0
- {metergraph-0.6.5 → metergraph-0.6.7}/src/metergraph/__init__.py +0 -0
- {metergraph-0.6.5 → metergraph-0.6.7}/src/metergraph/_batch_first.py +0 -0
- {metergraph-0.6.5 → metergraph-0.6.7}/src/metergraph/_config.py +0 -0
- {metergraph-0.6.5 → metergraph-0.6.7}/src/metergraph/_context.py +0 -0
- {metergraph-0.6.5 → metergraph-0.6.7}/src/metergraph/_failure_log.py +0 -0
- {metergraph-0.6.5 → metergraph-0.6.7}/src/metergraph/_provider_batch.py +0 -0
- {metergraph-0.6.5 → metergraph-0.6.7}/src/metergraph/_repo_config.py +0 -0
- {metergraph-0.6.5 → metergraph-0.6.7}/src/metergraph/_session.py +0 -0
- {metergraph-0.6.5 → metergraph-0.6.7}/src/metergraph/_track.py +0 -0
- {metergraph-0.6.5 → metergraph-0.6.7}/src/metergraph/_transport.py +0 -0
- {metergraph-0.6.5 → metergraph-0.6.7}/src/metergraph.egg-info/dependency_links.txt +0 -0
- {metergraph-0.6.5 → metergraph-0.6.7}/src/metergraph.egg-info/requires.txt +0 -0
- {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.
|
|
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
|
|
@@ -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
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
|
345
|
+
return json_value(content)
|
|
341
346
|
parsed = _get(message, "parsed")
|
|
342
347
|
if parsed is not None:
|
|
343
|
-
return
|
|
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
|
|
354
|
+
return json_value(blocks)
|
|
350
355
|
outputs = _get(response, "output")
|
|
351
356
|
if outputs is not None:
|
|
352
|
-
return
|
|
357
|
+
return json_value(outputs)
|
|
353
358
|
candidates = _get(response, "candidates")
|
|
354
359
|
if candidates is not None:
|
|
355
|
-
return
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|