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.
- {metergraph-0.6.6/src/metergraph.egg-info → metergraph-0.6.7}/PKG-INFO +1 -1
- {metergraph-0.6.6 → metergraph-0.6.7}/pyproject.toml +1 -1
- {metergraph-0.6.6 → metergraph-0.6.7}/src/metergraph/_capture.py +17 -12
- metergraph-0.6.7/src/metergraph/_template.py +149 -0
- {metergraph-0.6.6 → metergraph-0.6.7}/src/metergraph/_version.py +1 -1
- {metergraph-0.6.6 → metergraph-0.6.7/src/metergraph.egg-info}/PKG-INFO +1 -1
- metergraph-0.6.6/src/metergraph/_template.py +0 -80
- {metergraph-0.6.6 → metergraph-0.6.7}/MANIFEST.in +0 -0
- {metergraph-0.6.6 → metergraph-0.6.7}/README.md +0 -0
- {metergraph-0.6.6 → metergraph-0.6.7}/setup.cfg +0 -0
- {metergraph-0.6.6 → metergraph-0.6.7}/src/metergraph/__init__.py +0 -0
- {metergraph-0.6.6 → metergraph-0.6.7}/src/metergraph/_batch_first.py +0 -0
- {metergraph-0.6.6 → metergraph-0.6.7}/src/metergraph/_config.py +0 -0
- {metergraph-0.6.6 → metergraph-0.6.7}/src/metergraph/_context.py +0 -0
- {metergraph-0.6.6 → metergraph-0.6.7}/src/metergraph/_failure_log.py +0 -0
- {metergraph-0.6.6 → metergraph-0.6.7}/src/metergraph/_gateway.py +0 -0
- {metergraph-0.6.6 → metergraph-0.6.7}/src/metergraph/_genai_attrs.py +0 -0
- {metergraph-0.6.6 → metergraph-0.6.7}/src/metergraph/_provider_batch.py +0 -0
- {metergraph-0.6.6 → metergraph-0.6.7}/src/metergraph/_repo_config.py +0 -0
- {metergraph-0.6.6 → metergraph-0.6.7}/src/metergraph/_session.py +0 -0
- {metergraph-0.6.6 → metergraph-0.6.7}/src/metergraph/_track.py +0 -0
- {metergraph-0.6.6 → metergraph-0.6.7}/src/metergraph/_transport.py +0 -0
- {metergraph-0.6.6 → metergraph-0.6.7}/src/metergraph/opentelemetry.py +0 -0
- {metergraph-0.6.6 → metergraph-0.6.7}/src/metergraph.egg-info/SOURCES.txt +0 -0
- {metergraph-0.6.6 → metergraph-0.6.7}/src/metergraph.egg-info/dependency_links.txt +0 -0
- {metergraph-0.6.6 → metergraph-0.6.7}/src/metergraph.egg-info/requires.txt +0 -0
- {metergraph-0.6.6 → metergraph-0.6.7}/src/metergraph.egg-info/top_level.txt +0 -0
|
@@ -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",
|
|
@@ -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()
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|