metergraph 0.6.6__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 (27) hide show
  1. {metergraph-0.6.6/src/metergraph.egg-info → metergraph-0.6.7}/PKG-INFO +1 -1
  2. {metergraph-0.6.6 → metergraph-0.6.7}/pyproject.toml +1 -1
  3. {metergraph-0.6.6 → metergraph-0.6.7}/src/metergraph/_capture.py +17 -12
  4. metergraph-0.6.7/src/metergraph/_template.py +149 -0
  5. {metergraph-0.6.6 → metergraph-0.6.7}/src/metergraph/_version.py +1 -1
  6. {metergraph-0.6.6 → metergraph-0.6.7/src/metergraph.egg-info}/PKG-INFO +1 -1
  7. metergraph-0.6.6/src/metergraph/_template.py +0 -80
  8. {metergraph-0.6.6 → metergraph-0.6.7}/MANIFEST.in +0 -0
  9. {metergraph-0.6.6 → metergraph-0.6.7}/README.md +0 -0
  10. {metergraph-0.6.6 → metergraph-0.6.7}/setup.cfg +0 -0
  11. {metergraph-0.6.6 → metergraph-0.6.7}/src/metergraph/__init__.py +0 -0
  12. {metergraph-0.6.6 → metergraph-0.6.7}/src/metergraph/_batch_first.py +0 -0
  13. {metergraph-0.6.6 → metergraph-0.6.7}/src/metergraph/_config.py +0 -0
  14. {metergraph-0.6.6 → metergraph-0.6.7}/src/metergraph/_context.py +0 -0
  15. {metergraph-0.6.6 → metergraph-0.6.7}/src/metergraph/_failure_log.py +0 -0
  16. {metergraph-0.6.6 → metergraph-0.6.7}/src/metergraph/_gateway.py +0 -0
  17. {metergraph-0.6.6 → metergraph-0.6.7}/src/metergraph/_genai_attrs.py +0 -0
  18. {metergraph-0.6.6 → metergraph-0.6.7}/src/metergraph/_provider_batch.py +0 -0
  19. {metergraph-0.6.6 → metergraph-0.6.7}/src/metergraph/_repo_config.py +0 -0
  20. {metergraph-0.6.6 → metergraph-0.6.7}/src/metergraph/_session.py +0 -0
  21. {metergraph-0.6.6 → metergraph-0.6.7}/src/metergraph/_track.py +0 -0
  22. {metergraph-0.6.6 → metergraph-0.6.7}/src/metergraph/_transport.py +0 -0
  23. {metergraph-0.6.6 → metergraph-0.6.7}/src/metergraph/opentelemetry.py +0 -0
  24. {metergraph-0.6.6 → metergraph-0.6.7}/src/metergraph.egg-info/SOURCES.txt +0 -0
  25. {metergraph-0.6.6 → metergraph-0.6.7}/src/metergraph.egg-info/dependency_links.txt +0 -0
  26. {metergraph-0.6.6 → metergraph-0.6.7}/src/metergraph.egg-info/requires.txt +0 -0
  27. {metergraph-0.6.6 → 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.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "metergraph"
3
- version = "0.6.6"
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",
@@ -0,0 +1,149 @@
1
+ """Stable, privacy-conscious request fingerprinting for untagged traffic."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import hashlib
7
+ import json
8
+ import re
9
+ from collections.abc import Mapping, Sequence
10
+ from typing import Any
11
+
12
+
13
+ _UUID = re.compile(r"\b[0-9a-f]{8}-[0-9a-f-]{27,}\b", re.I)
14
+ _EMAIL = re.compile(r"\b[^\s@]+@[^\s@]+\.[^\s@]+\b")
15
+ _URL = re.compile(r"\bhttps?://\S+")
16
+ _NUMBER = re.compile(r"(?<![A-Za-z])[-+]?\d+(?:\.\d+)?(?![A-Za-z])")
17
+ _LONG_TOKEN = re.compile(r"\b[A-Za-z0-9_-]{24,}\b")
18
+ _SENSITIVE_KEYS = {
19
+ "api-key",
20
+ "api_key",
21
+ "apikey",
22
+ "authorization",
23
+ "client_secret",
24
+ "cookie",
25
+ "headers",
26
+ "id_token",
27
+ "password",
28
+ "proxy-authorization",
29
+ "refresh_token",
30
+ "secret",
31
+ "set-cookie",
32
+ "token",
33
+ "x-api-key",
34
+ }
35
+
36
+
37
+ def _normalize_text(value: str) -> str:
38
+ value = _UUID.sub("<uuid>", value)
39
+ value = _EMAIL.sub("<email>", value)
40
+ value = _URL.sub("<url>", value)
41
+ value = _LONG_TOKEN.sub("<token>", value)
42
+ value = _NUMBER.sub("<n>", value)
43
+ return " ".join(value.split())
44
+
45
+
46
+ # Header and query containers carry transport credentials and nothing analysis
47
+ # reads, so they are dropped whole. Keys compare lower-cased, so camelCase
48
+ # client options match.
49
+ _TRANSPORT_CONTAINERS = {
50
+ "default_headers",
51
+ "default_query",
52
+ "defaultheaders",
53
+ "defaultquery",
54
+ "extra_headers",
55
+ "extra_query",
56
+ "extraheaders",
57
+ "extraquery",
58
+ "headers",
59
+ "query",
60
+ }
61
+
62
+
63
+ def json_value(value: Any) -> Any:
64
+ """`value` as plain JSON data, with nothing removed."""
65
+ model_dump = getattr(value, "model_dump", None)
66
+ if callable(model_dump):
67
+ try:
68
+ return json_value(model_dump(mode="json", exclude_none=True))
69
+ except Exception:
70
+ pass
71
+ # The Python-mode dump keeps the structure analysis reads; only the
72
+ # values JSON cannot hold fall back to text.
73
+ try:
74
+ return json_value(model_dump(exclude_none=True))
75
+ except Exception:
76
+ return repr(value)
77
+ if dataclasses.is_dataclass(value) and not isinstance(value, type):
78
+ return json_value({
79
+ field.name: getattr(value, field.name)
80
+ for field in dataclasses.fields(value)
81
+ if getattr(value, field.name) is not None
82
+ })
83
+ if isinstance(value, Mapping):
84
+ return {str(k): json_value(v) for k, v in value.items()}
85
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
86
+ return [json_value(item) for item in value]
87
+ if isinstance(value, str):
88
+ return value
89
+ if isinstance(value, (int, float, bool)) or value is None:
90
+ return value
91
+ return repr(value)
92
+
93
+
94
+ def _credential_key(key: str) -> bool:
95
+ key = key.strip().lower()
96
+ return key in _SENSITIVE_KEYS or key in _TRANSPORT_CONTAINERS
97
+
98
+
99
+ def scrub_request(request: Any) -> Any:
100
+ """The provider request as captured: JSON data without its credentials.
101
+
102
+ Credentials travel as request-level parameters and in header or query
103
+ containers. `extra_body` is merged into the request body, so its keys are
104
+ request-level too. Messages, tools and schemas are kept exactly as sent,
105
+ even where a name matches a credential's (a tool parameter called `token`),
106
+ because they are the request analysis replays.
107
+ """
108
+ value = json_value(request)
109
+ if not isinstance(value, Mapping):
110
+ return value
111
+ clean = {k: v for k, v in value.items() if not _credential_key(k)}
112
+ extra_body = clean.get("extra_body")
113
+ if isinstance(extra_body, Mapping):
114
+ clean["extra_body"] = {
115
+ k: v for k, v in extra_body.items() if not _credential_key(k)
116
+ }
117
+ return clean
118
+
119
+
120
+ def _without_credential_names(value: Any) -> Any:
121
+ if isinstance(value, Mapping):
122
+ return {
123
+ k: _without_credential_names(v)
124
+ for k, v in value.items()
125
+ if k.strip().lower() not in _SENSITIVE_KEYS
126
+ }
127
+ if isinstance(value, list):
128
+ return [_without_credential_names(item) for item in value]
129
+ return value
130
+
131
+
132
+ def template_hash(request: Mapping[str, Any]) -> str:
133
+ def skeleton(value: Any) -> Any:
134
+ if isinstance(value, Mapping):
135
+ return {str(k): skeleton(v) for k, v in sorted(value.items())}
136
+ if isinstance(value, list):
137
+ return [skeleton(item) for item in value]
138
+ if isinstance(value, str):
139
+ return _normalize_text(value)
140
+ return value
141
+
142
+ # Credential names are left out at every depth. This hash routes unnamed
143
+ # workloads, so its input must stay stable across SDK versions.
144
+ encoded = json.dumps(
145
+ skeleton(_without_credential_names(json_value(request))),
146
+ sort_keys=True,
147
+ separators=(",", ":"),
148
+ )
149
+ return hashlib.sha256(encoded.encode()).hexdigest()
@@ -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.6"
11
+ SDK_VERSION = "0.6.7"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: metergraph
3
- Version: 0.6.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
@@ -1,80 +0,0 @@
1
- """Stable, privacy-conscious request fingerprinting for untagged traffic."""
2
-
3
- from __future__ import annotations
4
-
5
- import hashlib
6
- import json
7
- import re
8
- from collections.abc import Mapping, Sequence
9
- from typing import Any
10
-
11
-
12
- _UUID = re.compile(r"\b[0-9a-f]{8}-[0-9a-f-]{27,}\b", re.I)
13
- _EMAIL = re.compile(r"\b[^\s@]+@[^\s@]+\.[^\s@]+\b")
14
- _URL = re.compile(r"\bhttps?://\S+")
15
- _NUMBER = re.compile(r"(?<![A-Za-z])[-+]?\d+(?:\.\d+)?(?![A-Za-z])")
16
- _LONG_TOKEN = re.compile(r"\b[A-Za-z0-9_-]{24,}\b")
17
- _SENSITIVE_KEYS = {
18
- "api-key",
19
- "api_key",
20
- "apikey",
21
- "authorization",
22
- "client_secret",
23
- "cookie",
24
- "headers",
25
- "id_token",
26
- "password",
27
- "proxy-authorization",
28
- "refresh_token",
29
- "secret",
30
- "set-cookie",
31
- "token",
32
- "x-api-key",
33
- }
34
-
35
-
36
- def _normalize_text(value: str) -> str:
37
- value = _UUID.sub("<uuid>", value)
38
- value = _EMAIL.sub("<email>", value)
39
- value = _URL.sub("<url>", value)
40
- value = _LONG_TOKEN.sub("<token>", value)
41
- value = _NUMBER.sub("<n>", value)
42
- return " ".join(value.split())
43
-
44
-
45
- def scrub(value: Any) -> Any:
46
- model_dump = getattr(value, "model_dump", None)
47
- if callable(model_dump):
48
- try:
49
- return scrub(model_dump(mode="json", exclude_none=True))
50
- except Exception:
51
- return repr(value)
52
- if isinstance(value, Mapping):
53
- return {
54
- str(k): scrub(v)
55
- for k, v in value.items()
56
- if str(k).strip().lower() not in _SENSITIVE_KEYS
57
- }
58
- if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
59
- return [scrub(item) for item in value]
60
- if isinstance(value, str):
61
- return value
62
- if isinstance(value, (int, float, bool)) or value is None:
63
- return value
64
- return repr(value)
65
-
66
-
67
- def template_hash(request: Mapping[str, Any]) -> str:
68
- def skeleton(value: Any) -> Any:
69
- if isinstance(value, Mapping):
70
- return {str(k): skeleton(v) for k, v in sorted(value.items())}
71
- if isinstance(value, list):
72
- return [skeleton(item) for item in value]
73
- if isinstance(value, str):
74
- return _normalize_text(value)
75
- return value
76
-
77
- encoded = json.dumps(
78
- skeleton(scrub(request)), sort_keys=True, separators=(",", ":")
79
- )
80
- return hashlib.sha256(encoded.encode()).hexdigest()
File without changes
File without changes
File without changes