@ictechgy/context-guard 0.4.15 → 0.4.16
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.
- package/CHANGELOG.md +8 -0
- package/README.ko.md +46 -1
- package/README.md +58 -2
- package/package.json +1 -1
- package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
- package/plugins/context-guard/README.ko.md +18 -0
- package/plugins/context-guard/README.md +18 -0
- package/plugins/context-guard/bin/context-guard-artifact +90 -9
- package/plugins/context-guard/bin/context-guard-audit +169 -66
- package/plugins/context-guard/bin/context-guard-bench +5765 -224
- package/plugins/context-guard/bin/context-guard-compress +90 -8
- package/plugins/context-guard/bin/context-guard-diet +1 -7
- package/plugins/context-guard/bin/context-guard-experiments +5 -1
- package/plugins/context-guard/bin/context-guard-failed-nudge +705 -83
- package/plugins/context-guard/bin/context-guard-guard-read +490 -55
- package/plugins/context-guard/bin/context-guard-pack +110 -11
- package/plugins/context-guard/bin/context-guard-read-symbol +7 -2
- package/plugins/context-guard/bin/context-guard-rewrite-bash +2204 -223
- package/plugins/context-guard/bin/context-guard-sanitize-output +560 -85
- package/plugins/context-guard/bin/context-guard-setup +1073 -147
- package/plugins/context-guard/bin/context-guard-statusline +131 -54
- package/plugins/context-guard/bin/context-guard-statusline-merged +7 -3
- package/plugins/context-guard/bin/context-guard-tool-prune +44 -11
- package/plugins/context-guard/bin/context-guard-trim-output +89 -13
- package/plugins/context-guard/brief/README.md +19 -0
- package/plugins/context-guard/brief/narration-mode.quiet.md +21 -0
- package/plugins/context-guard/lib/context_guard_commands.py +6 -2
- package/plugins/context-guard/lib/credential_policy.py +177 -0
- package/plugins/context-guard/lib/transcript_usage_reducer.py +378 -0
|
@@ -29,9 +29,11 @@ IMPLEMENTATION_PAIRS = (('context_guard_cli.py', 'context-guard'),
|
|
|
29
29
|
('trim_command_output.py', 'context-guard-trim-output'))
|
|
30
30
|
|
|
31
31
|
HELPER_PAIRS = (('hook_secret_patterns.py', 'lib/hook_secret_patterns.py'),
|
|
32
|
+
('credential_policy.py', 'lib/credential_policy.py'),
|
|
32
33
|
('context_guard_commands.py', 'lib/context_guard_commands.py'),
|
|
33
34
|
('context_guard_command_manifest_loader.py',
|
|
34
|
-
'lib/context_guard_command_manifest_loader.py')
|
|
35
|
+
'lib/context_guard_command_manifest_loader.py'),
|
|
36
|
+
('transcript_usage_reducer.py', 'lib/transcript_usage_reducer.py'))
|
|
35
37
|
|
|
36
38
|
NPM_BINS = ('context-guard',
|
|
37
39
|
'context-guard-cost',
|
|
@@ -236,6 +238,8 @@ EXPECTED_COMMAND_PACK_FILES = ('plugins/context-guard/bin/claude-read-symbol',
|
|
|
236
238
|
'plugins/context-guard/bin/context-guard-statusline-merged',
|
|
237
239
|
'plugins/context-guard/bin/context-guard-tool-prune',
|
|
238
240
|
'plugins/context-guard/bin/context-guard-trim-output',
|
|
241
|
+
'plugins/context-guard/lib/credential_policy.py',
|
|
239
242
|
'plugins/context-guard/lib/context_guard_command_manifest_loader.py',
|
|
240
243
|
'plugins/context-guard/lib/context_guard_commands.py',
|
|
241
|
-
'plugins/context-guard/lib/hook_secret_patterns.py'
|
|
244
|
+
'plugins/context-guard/lib/hook_secret_patterns.py',
|
|
245
|
+
'plugins/context-guard/lib/transcript_usage_reducer.py')
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Pure shared credential classification and high-confidence redaction policy."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
SECRET_KEY = (
|
|
8
|
+
r"[A-Za-z0-9_.-]*(?:api[_-]?key|apikey|token|secret|password|passwd|pwd|"
|
|
9
|
+
r"private[_-]?key|access[_-]?key|client[_-]?secret|credential|signature|sig|"
|
|
10
|
+
r"ssh[_-]?key|pgp[_-]?private[_-]?key)[A-Za-z0-9_.-]*"
|
|
11
|
+
r"|(?:session(?:[_-]?(?:id|token))?|sessionid|sid|jsessionid|"
|
|
12
|
+
r"csrf(?:[_-]?token)?|xsrf(?:[_-]?token)?)"
|
|
13
|
+
r"|AWS_ACCESS_KEY_ID|AWS_SECRET_ACCESS_KEY|AWS_SESSION_TOKEN|"
|
|
14
|
+
r"GOOGLE_APPLICATION_CREDENTIALS|AZURE_CLIENT_SECRET"
|
|
15
|
+
)
|
|
16
|
+
URL_LIKE_RE = re.compile(r"\b[A-Za-z][A-Za-z0-9+.-]*://[^\s]+")
|
|
17
|
+
URL_SECRET_PARAM_RE = re.compile(rf"(?i)([?&#;](?:{SECRET_KEY})=)[^\s?&#;]+")
|
|
18
|
+
SCHEMELESS_SECRET_PARAM_RE = re.compile(rf"(?i)([?&#](?:{SECRET_KEY})=)[^\s?&#;]+")
|
|
19
|
+
CAMEL_ACRONYM_BOUNDARY_RE = re.compile(r"(?<=[A-Z])(?=[A-Z][a-z])")
|
|
20
|
+
CAMEL_WORD_BOUNDARY_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
|
|
21
|
+
EXACT_SENSITIVE_KEYS = frozenset(
|
|
22
|
+
{
|
|
23
|
+
"access_key",
|
|
24
|
+
"access_key_id",
|
|
25
|
+
"access_token",
|
|
26
|
+
"api_key",
|
|
27
|
+
"apikey",
|
|
28
|
+
"auth",
|
|
29
|
+
"authorization",
|
|
30
|
+
"aws_access_key_id",
|
|
31
|
+
"aws_secret_access_key",
|
|
32
|
+
"aws_session_token",
|
|
33
|
+
"azure_client_secret",
|
|
34
|
+
"client_secret",
|
|
35
|
+
"cookie",
|
|
36
|
+
"credential",
|
|
37
|
+
"credentials",
|
|
38
|
+
"csrf",
|
|
39
|
+
"csrf_token",
|
|
40
|
+
"google_application_credentials",
|
|
41
|
+
"id_token",
|
|
42
|
+
"jsessionid",
|
|
43
|
+
"jwt",
|
|
44
|
+
"password",
|
|
45
|
+
"passwd",
|
|
46
|
+
"private_key",
|
|
47
|
+
"privatekey",
|
|
48
|
+
"proxy_authorization",
|
|
49
|
+
"pwd",
|
|
50
|
+
"refresh_token",
|
|
51
|
+
"secret",
|
|
52
|
+
"session",
|
|
53
|
+
"session_id",
|
|
54
|
+
"session_token",
|
|
55
|
+
"sessionid",
|
|
56
|
+
"set_cookie",
|
|
57
|
+
"sid",
|
|
58
|
+
"sig",
|
|
59
|
+
"signature",
|
|
60
|
+
"ssh_key",
|
|
61
|
+
"sshkey",
|
|
62
|
+
"token",
|
|
63
|
+
"x_amz_credential",
|
|
64
|
+
"x_amz_security_token",
|
|
65
|
+
"x_amz_signature",
|
|
66
|
+
"xsrf",
|
|
67
|
+
"xsrf_token",
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
SENSITIVE_KEY_SUFFIXES = (
|
|
71
|
+
"_access_key",
|
|
72
|
+
"_access_token",
|
|
73
|
+
"_api_key",
|
|
74
|
+
"_client_secret",
|
|
75
|
+
"_credential",
|
|
76
|
+
"_credentials",
|
|
77
|
+
"_password",
|
|
78
|
+
"_private_key",
|
|
79
|
+
"_refresh_token",
|
|
80
|
+
"_secret",
|
|
81
|
+
"_secret_key",
|
|
82
|
+
"_session_token",
|
|
83
|
+
"_token",
|
|
84
|
+
)
|
|
85
|
+
SENSITIVE_KEY_QUALIFIER_RE = re.compile(
|
|
86
|
+
r"(?:^|_)(?:api_?key|apikey|token|secret|password|passwd|pwd|"
|
|
87
|
+
r"private_key|access_key|client_secret|credential|signature|sig|"
|
|
88
|
+
r"session_id|session_token)(?:_(?:v\d+|prod|production|dev|test|backup))?$"
|
|
89
|
+
)
|
|
90
|
+
INLINE_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
|
|
91
|
+
(re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+"), "[REDACTED]"),
|
|
92
|
+
(re.compile(r"(?i)\bBasic\s+[A-Za-z0-9._~+/=-]+"), "[REDACTED]"),
|
|
93
|
+
(re.compile(r"(?i)(--(?:api[_-]?key|token|secret|password|client[_-]?secret)\s+)\S+"), r"\1[REDACTED]"),
|
|
94
|
+
(re.compile(r"(?i)(--(?:api[_-]?key|token|secret|password|client[_-]?secret)=)\S+"), r"\1[REDACTED]"),
|
|
95
|
+
(re.compile(r"(?i)((?:-p|-u|--user)\s+)\S+:\S+"), r"\1[REDACTED]"),
|
|
96
|
+
(re.compile(r"gh[pousr]_[A-Za-z0-9_]{20,}"), "[REDACTED]"),
|
|
97
|
+
(re.compile(r"github_pat_[A-Za-z0-9_]{20,}"), "[REDACTED]"),
|
|
98
|
+
(re.compile(r"glpat-[A-Za-z0-9_-]{12,}"), "[REDACTED]"),
|
|
99
|
+
(re.compile(r"xox[abprs]-[A-Za-z0-9-]{10,}"), "[REDACTED]"),
|
|
100
|
+
(re.compile(r"(?:AKIA|ASIA)[0-9A-Z]{16}"), "[REDACTED]"),
|
|
101
|
+
(re.compile(r"(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{16,}"), "[REDACTED]"),
|
|
102
|
+
(re.compile(r"sk-(?:ant|proj)-[A-Za-z0-9_-]{12,}"), "[REDACTED]"),
|
|
103
|
+
(re.compile(r"sk-[A-Za-z0-9][A-Za-z0-9_-]{20,}"), "[REDACTED]"),
|
|
104
|
+
(re.compile(r"npm_[A-Za-z0-9]{20,}"), "[REDACTED]"),
|
|
105
|
+
(re.compile(r"AIza[0-9A-Za-z_\-]{20,}"), "[REDACTED]"),
|
|
106
|
+
(re.compile(r"SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}"), "[REDACTED]"),
|
|
107
|
+
(re.compile(r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+"), "[REDACTED]"),
|
|
108
|
+
# FIX-6: URL userinfo 리댁션 — 비밀번호 파트(`:pass`)를 선택적으로 만들어
|
|
109
|
+
# `scheme://TOKEN@host`(콜론 없는 토큰 전용 형태, 가장 흔한 PAT 임베딩 방식)도
|
|
110
|
+
# 함께 잡는다. 기존 패턴은 `user:pass@` 두 부분을 모두 요구해 벤더 접두사가
|
|
111
|
+
# 없는 토큰(Azure DevOps PAT, 사내 PAT, 범용 토큰 등 — `ghp_`/`github_pat_`/
|
|
112
|
+
# `glpat-`처럼 위에서 별도 패턴이 잡는 벤더가 아닌 토큰)을 통과시켰다(실측
|
|
113
|
+
# 3/3 누수). `scheme://` 접두사는 여전히 필수이므로 `git log` 저자 줄의
|
|
114
|
+
# `<user@host>` 같은 스킴 없는 평범한 이메일 언급은 과잉 리댁션하지 않는다
|
|
115
|
+
# — 이 경계는 sanitizer_mode_cases의 `bare_userinfo_without_scheme` 음성
|
|
116
|
+
# 케이스로 고정한다(tests/context_guard_a1_oracles.py).
|
|
117
|
+
(re.compile(r"([a-z][a-z0-9+.-]*://)[^/\s:@]+(?::[^/\s@]+)?@", re.IGNORECASE), r"\1[REDACTED]@"),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def normalize_sensitive_key(key: str) -> str:
|
|
122
|
+
key = CAMEL_ACRONYM_BOUNDARY_RE.sub("_", key)
|
|
123
|
+
key = CAMEL_WORD_BOUNDARY_RE.sub("_", key)
|
|
124
|
+
key = re.sub(r"[_.-]+", "_", key)
|
|
125
|
+
return re.sub(r"_+", "_", key).strip("_").lower()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def is_sensitive_key(key: str) -> bool:
|
|
129
|
+
normalized = normalize_sensitive_key(key.strip().strip("\"'"))
|
|
130
|
+
return (
|
|
131
|
+
normalized in EXACT_SENSITIVE_KEYS
|
|
132
|
+
or normalized.endswith(SENSITIVE_KEY_SUFFIXES)
|
|
133
|
+
or SENSITIVE_KEY_QUALIFIER_RE.search(normalized) is not None
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def redact_url_like_secret_params(line: str) -> tuple[str, bool]:
|
|
138
|
+
redacted = False
|
|
139
|
+
|
|
140
|
+
def url_repl(match: re.Match[str]) -> str:
|
|
141
|
+
nonlocal redacted
|
|
142
|
+
|
|
143
|
+
def param_repl(param_match: re.Match[str]) -> str:
|
|
144
|
+
nonlocal redacted
|
|
145
|
+
prefix = param_match.group(1)
|
|
146
|
+
key = prefix[1:].split("=", 1)[0]
|
|
147
|
+
if not is_sensitive_key(key):
|
|
148
|
+
return param_match.group(0)
|
|
149
|
+
redacted = True
|
|
150
|
+
return prefix + "[REDACTED]"
|
|
151
|
+
|
|
152
|
+
return URL_SECRET_PARAM_RE.sub(param_repl, match.group(0))
|
|
153
|
+
|
|
154
|
+
line = URL_LIKE_RE.sub(url_repl, line)
|
|
155
|
+
|
|
156
|
+
def fragment_repl(param_match: re.Match[str]) -> str:
|
|
157
|
+
nonlocal redacted
|
|
158
|
+
prefix = param_match.group(1)
|
|
159
|
+
key = prefix[1:].split("=", 1)[0]
|
|
160
|
+
if not is_sensitive_key(key):
|
|
161
|
+
return param_match.group(0)
|
|
162
|
+
redacted = True
|
|
163
|
+
return prefix + "[REDACTED]"
|
|
164
|
+
|
|
165
|
+
return SCHEMELESS_SECRET_PARAM_RE.sub(fragment_repl, line), redacted
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def redact_high_confidence_credentials(text: str) -> tuple[str, int]:
|
|
169
|
+
"""Redact context-independent credential values without path policy."""
|
|
170
|
+
redactions = 0
|
|
171
|
+
text, url_redacted = redact_url_like_secret_params(text)
|
|
172
|
+
if url_redacted:
|
|
173
|
+
redactions += 1
|
|
174
|
+
for pattern, replacement in INLINE_PATTERNS:
|
|
175
|
+
text, count = pattern.subn(replacement, text)
|
|
176
|
+
redactions += count
|
|
177
|
+
return text, redactions
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Deterministic transcript usage reduction shared by audit and statusline.
|
|
3
|
+
|
|
4
|
+
Token totals intentionally recognize one Claude transcript shape:
|
|
5
|
+
``row.message.usage`` with the model at ``row.message.model``. Other bounded
|
|
6
|
+
usage-like shapes are not summed, but are recorded as ineligible and make the
|
|
7
|
+
result partial so schema drift cannot silently look complete. Repeated
|
|
8
|
+
response rows are grouped before summing so snapshots, streaming updates, and
|
|
9
|
+
nested usage lookalikes cannot be counted as independent usage.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections import defaultdict
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
import datetime as _dt
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import math
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
from typing import Any, Iterable
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
REDUCER_SCHEMA = "usage-reducer-v2"
|
|
25
|
+
UINT63_MAX = (1 << 63) - 1
|
|
26
|
+
FILE_IDENTITY_RE = re.compile(r"^[0-9a-fA-F]{64}$")
|
|
27
|
+
TOKEN_FIELDS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
|
28
|
+
("input", ("input_tokens",)),
|
|
29
|
+
("output", ("output_tokens",)),
|
|
30
|
+
("cache_creation", ("cache_creation_input_tokens", "cacheCreation")),
|
|
31
|
+
("cache_read", ("cache_read_input_tokens", "cacheRead")),
|
|
32
|
+
)
|
|
33
|
+
TIMESTAMP_KEYS = ("timestamp", "created_at", "createdAt", "time", "ts")
|
|
34
|
+
COUNTER_KEYS = (
|
|
35
|
+
"observed_rows",
|
|
36
|
+
"eligible_candidates",
|
|
37
|
+
"selected_candidates",
|
|
38
|
+
"usage_conflict",
|
|
39
|
+
"numeric_overflow",
|
|
40
|
+
"invalid_numeric",
|
|
41
|
+
"invalid_row",
|
|
42
|
+
"no_id_fallback",
|
|
43
|
+
"ineligible_usage_shape",
|
|
44
|
+
)
|
|
45
|
+
USAGE_LIKE_NODE_LIMIT = 4096
|
|
46
|
+
USAGE_LIKE_DEPTH_LIMIT = 64
|
|
47
|
+
TOKEN_FIELD_ALIASES = frozenset(alias for _bucket, aliases in TOKEN_FIELDS for alias in aliases)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def hash_file_identity(path: str | os.PathLike[str]) -> str:
|
|
51
|
+
"""Hash a canonical local transcript identity without returning its path."""
|
|
52
|
+
canonical = os.path.realpath(os.path.abspath(os.fspath(path)))
|
|
53
|
+
return hashlib.sha256(os.fsencode(canonical)).hexdigest()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def canonical_row_sha256(row: Any) -> str | None:
|
|
57
|
+
"""Hash a complete canonical JSON row, rejecting non-JSON numeric values."""
|
|
58
|
+
try:
|
|
59
|
+
encoded = json.dumps(
|
|
60
|
+
row,
|
|
61
|
+
ensure_ascii=False,
|
|
62
|
+
sort_keys=True,
|
|
63
|
+
separators=(",", ":"),
|
|
64
|
+
allow_nan=False,
|
|
65
|
+
).encode("utf-8")
|
|
66
|
+
except (TypeError, ValueError, OverflowError, RecursionError, UnicodeError):
|
|
67
|
+
return None
|
|
68
|
+
return hashlib.sha256(encoded).hexdigest()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _canonical_file_identity(value: str) -> str:
|
|
72
|
+
text = str(value)
|
|
73
|
+
if FILE_IDENTITY_RE.fullmatch(text):
|
|
74
|
+
return text.lower()
|
|
75
|
+
return hashlib.sha256(text.encode("utf-8", errors="surrogatepass")).hexdigest()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _session_id(row: dict[str, Any]) -> str:
|
|
79
|
+
for key in ("session_id", "sessionId"):
|
|
80
|
+
value = row.get(key)
|
|
81
|
+
if isinstance(value, str):
|
|
82
|
+
return value
|
|
83
|
+
return ""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _message_id(message: dict[str, Any]) -> str | None:
|
|
87
|
+
value = message.get("id")
|
|
88
|
+
if isinstance(value, str) and value:
|
|
89
|
+
return value
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _model(message: dict[str, Any]) -> str:
|
|
94
|
+
value = message.get("model")
|
|
95
|
+
if not isinstance(value, str):
|
|
96
|
+
return "unknown"
|
|
97
|
+
safe_value = value.encode("utf-8", errors="replace").decode("utf-8")
|
|
98
|
+
compact = " ".join(safe_value.strip().split())
|
|
99
|
+
return compact[:120] or "unknown"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _parse_timestamp(value: Any) -> _dt.datetime | None:
|
|
103
|
+
if isinstance(value, bool):
|
|
104
|
+
return None
|
|
105
|
+
if isinstance(value, str):
|
|
106
|
+
text = value.strip()
|
|
107
|
+
if not text:
|
|
108
|
+
return None
|
|
109
|
+
try:
|
|
110
|
+
if text.endswith("Z"):
|
|
111
|
+
text = text[:-1] + "+00:00"
|
|
112
|
+
parsed = _dt.datetime.fromisoformat(text)
|
|
113
|
+
except ValueError:
|
|
114
|
+
return None
|
|
115
|
+
if parsed.tzinfo is None:
|
|
116
|
+
parsed = parsed.replace(tzinfo=_dt.timezone.utc)
|
|
117
|
+
try:
|
|
118
|
+
return parsed.astimezone(_dt.timezone.utc)
|
|
119
|
+
except (OverflowError, ValueError):
|
|
120
|
+
return None
|
|
121
|
+
if isinstance(value, (int, float)) and math.isfinite(float(value)) and value >= 0:
|
|
122
|
+
seconds = float(value)
|
|
123
|
+
if seconds > 10_000_000_000:
|
|
124
|
+
seconds /= 1000.0
|
|
125
|
+
try:
|
|
126
|
+
return _dt.datetime.fromtimestamp(seconds, tz=_dt.timezone.utc)
|
|
127
|
+
except (OverflowError, OSError, ValueError):
|
|
128
|
+
return None
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _row_timestamp(row: dict[str, Any], message: dict[str, Any]) -> _dt.datetime | None:
|
|
133
|
+
for container in (row, message):
|
|
134
|
+
for key in TIMESTAMP_KEYS:
|
|
135
|
+
if key in container:
|
|
136
|
+
parsed = _parse_timestamp(container.get(key))
|
|
137
|
+
if parsed is not None:
|
|
138
|
+
return parsed
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _usage_values(usage: dict[str, Any]) -> tuple[tuple[str, int], ...] | None:
|
|
143
|
+
values: list[tuple[str, int]] = []
|
|
144
|
+
found = False
|
|
145
|
+
for bucket, aliases in TOKEN_FIELDS:
|
|
146
|
+
raw: Any = None
|
|
147
|
+
present = False
|
|
148
|
+
for alias in aliases:
|
|
149
|
+
if alias in usage:
|
|
150
|
+
raw = usage.get(alias)
|
|
151
|
+
present = True
|
|
152
|
+
break
|
|
153
|
+
if not present:
|
|
154
|
+
continue
|
|
155
|
+
found = True
|
|
156
|
+
if isinstance(raw, bool) or not isinstance(raw, int) or raw < 0 or raw > UINT63_MAX:
|
|
157
|
+
return None
|
|
158
|
+
values.append((bucket, raw))
|
|
159
|
+
if not found:
|
|
160
|
+
return ()
|
|
161
|
+
return tuple(values)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _contains_usage_like_tokens(root: Any) -> bool:
|
|
165
|
+
"""Boundedly detect token-bearing shapes outside ``row.message.usage``."""
|
|
166
|
+
stack: list[tuple[Any, int]] = [(root, 0)]
|
|
167
|
+
seen: set[int] = set()
|
|
168
|
+
visited = 0
|
|
169
|
+
while stack and visited < USAGE_LIKE_NODE_LIMIT:
|
|
170
|
+
value, depth = stack.pop()
|
|
171
|
+
visited += 1
|
|
172
|
+
if isinstance(value, dict):
|
|
173
|
+
identity = id(value)
|
|
174
|
+
if identity in seen:
|
|
175
|
+
continue
|
|
176
|
+
seen.add(identity)
|
|
177
|
+
if TOKEN_FIELD_ALIASES.intersection(value):
|
|
178
|
+
return True
|
|
179
|
+
if value.get("name") == "claude_code.token.usage":
|
|
180
|
+
return True
|
|
181
|
+
if depth < USAGE_LIKE_DEPTH_LIMIT:
|
|
182
|
+
stack.extend((child, depth + 1) for child in value.values())
|
|
183
|
+
elif isinstance(value, list):
|
|
184
|
+
identity = id(value)
|
|
185
|
+
if identity in seen:
|
|
186
|
+
continue
|
|
187
|
+
seen.add(identity)
|
|
188
|
+
if depth < USAGE_LIKE_DEPTH_LIMIT:
|
|
189
|
+
stack.extend((child, depth + 1) for child in value)
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@dataclass(frozen=True)
|
|
194
|
+
class UsageSelection:
|
|
195
|
+
file_identity: str
|
|
196
|
+
row_ordinal: int
|
|
197
|
+
tokens: dict[str, int]
|
|
198
|
+
present_buckets: tuple[str, ...]
|
|
199
|
+
model: str
|
|
200
|
+
timestamp: _dt.datetime | None
|
|
201
|
+
used_no_id_fallback: bool
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@dataclass(frozen=True)
|
|
205
|
+
class UsageReduction:
|
|
206
|
+
schema: str
|
|
207
|
+
tokens: dict[str, int]
|
|
208
|
+
by_model: dict[str, dict[str, int]]
|
|
209
|
+
counters: dict[str, int]
|
|
210
|
+
partial: bool
|
|
211
|
+
selections: tuple[UsageSelection, ...]
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@dataclass(frozen=True)
|
|
215
|
+
class _Candidate:
|
|
216
|
+
file_identity: str
|
|
217
|
+
row_ordinal: int
|
|
218
|
+
usage_items: tuple[tuple[str, int], ...]
|
|
219
|
+
model: str
|
|
220
|
+
timestamp: _dt.datetime | None
|
|
221
|
+
used_no_id_fallback: bool
|
|
222
|
+
|
|
223
|
+
@property
|
|
224
|
+
def value(self) -> tuple[tuple[str, int], ...]:
|
|
225
|
+
return self.usage_items
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def precedence(self) -> tuple[int, _dt.datetime, int]:
|
|
229
|
+
minimum = _dt.datetime.min.replace(tzinfo=_dt.timezone.utc)
|
|
230
|
+
return (1 if self.timestamp is not None else 0, self.timestamp or minimum, self.row_ordinal)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class UsageReducer:
|
|
234
|
+
"""Collect response candidates and finalize their selected aggregate."""
|
|
235
|
+
|
|
236
|
+
def __init__(self) -> None:
|
|
237
|
+
self._groups: dict[tuple[str, str, str], list[_Candidate]] = defaultdict(list)
|
|
238
|
+
self._counters = {key: 0 for key in COUNTER_KEYS}
|
|
239
|
+
self._partial = False
|
|
240
|
+
self._next_ordinal = 0
|
|
241
|
+
|
|
242
|
+
def note_invalid_row(self, count: int = 1) -> None:
|
|
243
|
+
amount = max(0, int(count))
|
|
244
|
+
if amount:
|
|
245
|
+
self._counters["invalid_row"] += amount
|
|
246
|
+
self._partial = True
|
|
247
|
+
|
|
248
|
+
def observe(
|
|
249
|
+
self,
|
|
250
|
+
row: Any,
|
|
251
|
+
*,
|
|
252
|
+
file_identity: str,
|
|
253
|
+
row_ordinal: int | None = None,
|
|
254
|
+
) -> bool:
|
|
255
|
+
ordinal = self._next_ordinal if row_ordinal is None else int(row_ordinal)
|
|
256
|
+
self._next_ordinal = max(self._next_ordinal + 1, ordinal + 1)
|
|
257
|
+
self._counters["observed_rows"] += 1
|
|
258
|
+
if not isinstance(row, dict):
|
|
259
|
+
self.note_invalid_row()
|
|
260
|
+
return False
|
|
261
|
+
message = row.get("message")
|
|
262
|
+
if not isinstance(message, dict):
|
|
263
|
+
if _contains_usage_like_tokens(row):
|
|
264
|
+
self._counters["ineligible_usage_shape"] += 1
|
|
265
|
+
self._partial = True
|
|
266
|
+
return False
|
|
267
|
+
usage = message.get("usage")
|
|
268
|
+
if not isinstance(usage, dict):
|
|
269
|
+
if _contains_usage_like_tokens(row):
|
|
270
|
+
self._counters["ineligible_usage_shape"] += 1
|
|
271
|
+
self._partial = True
|
|
272
|
+
return False
|
|
273
|
+
usage_items = _usage_values(usage)
|
|
274
|
+
if usage_items is None:
|
|
275
|
+
self._counters["invalid_numeric"] += 1
|
|
276
|
+
self._partial = True
|
|
277
|
+
return False
|
|
278
|
+
if not usage_items:
|
|
279
|
+
return False
|
|
280
|
+
|
|
281
|
+
canonical_file = _canonical_file_identity(file_identity)
|
|
282
|
+
message_id = _message_id(message)
|
|
283
|
+
used_no_id_fallback = message_id is None
|
|
284
|
+
if message_id is None:
|
|
285
|
+
row_digest = canonical_row_sha256(row)
|
|
286
|
+
if row_digest is None:
|
|
287
|
+
self.note_invalid_row()
|
|
288
|
+
return False
|
|
289
|
+
group_suffix = f"row:{row_digest}"
|
|
290
|
+
else:
|
|
291
|
+
group_suffix = f"id:{message_id}"
|
|
292
|
+
group_key = (canonical_file, _session_id(row), group_suffix)
|
|
293
|
+
first_for_group = group_key not in self._groups
|
|
294
|
+
candidate = _Candidate(
|
|
295
|
+
file_identity=canonical_file,
|
|
296
|
+
row_ordinal=ordinal,
|
|
297
|
+
usage_items=usage_items,
|
|
298
|
+
model=_model(message),
|
|
299
|
+
timestamp=_row_timestamp(row, message),
|
|
300
|
+
used_no_id_fallback=used_no_id_fallback,
|
|
301
|
+
)
|
|
302
|
+
self._groups[group_key].append(candidate)
|
|
303
|
+
self._counters["eligible_candidates"] += 1
|
|
304
|
+
if used_no_id_fallback and first_for_group:
|
|
305
|
+
self._counters["no_id_fallback"] += 1
|
|
306
|
+
return True
|
|
307
|
+
|
|
308
|
+
def extend(
|
|
309
|
+
self,
|
|
310
|
+
rows: Iterable[Any],
|
|
311
|
+
*,
|
|
312
|
+
file_identity: str,
|
|
313
|
+
start_ordinal: int | None = None,
|
|
314
|
+
) -> None:
|
|
315
|
+
ordinal = self._next_ordinal if start_ordinal is None else int(start_ordinal)
|
|
316
|
+
for row in rows:
|
|
317
|
+
self.observe(row, file_identity=file_identity, row_ordinal=ordinal)
|
|
318
|
+
ordinal += 1
|
|
319
|
+
|
|
320
|
+
def finalize(self) -> UsageReduction:
|
|
321
|
+
totals: dict[str, int] = defaultdict(int)
|
|
322
|
+
by_model: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
|
323
|
+
counters = dict(self._counters)
|
|
324
|
+
partial = self._partial
|
|
325
|
+
selections: list[UsageSelection] = []
|
|
326
|
+
|
|
327
|
+
for group_key in sorted(self._groups):
|
|
328
|
+
candidates = self._groups[group_key]
|
|
329
|
+
if len({candidate.value for candidate in candidates}) > 1:
|
|
330
|
+
counters["usage_conflict"] += 1
|
|
331
|
+
partial = True
|
|
332
|
+
selected = max(candidates, key=lambda candidate: candidate.precedence)
|
|
333
|
+
values = dict(selected.usage_items)
|
|
334
|
+
if any(totals[bucket] > UINT63_MAX - value for bucket, value in values.items()):
|
|
335
|
+
counters["numeric_overflow"] += 1
|
|
336
|
+
partial = True
|
|
337
|
+
continue
|
|
338
|
+
for bucket, value in values.items():
|
|
339
|
+
totals[bucket] += value
|
|
340
|
+
by_model[selected.model][bucket] += value
|
|
341
|
+
selections.append(
|
|
342
|
+
UsageSelection(
|
|
343
|
+
file_identity=selected.file_identity,
|
|
344
|
+
row_ordinal=selected.row_ordinal,
|
|
345
|
+
tokens={bucket: value for bucket, value in values.items() if value},
|
|
346
|
+
present_buckets=tuple(bucket for bucket, _value in selected.usage_items),
|
|
347
|
+
model=selected.model,
|
|
348
|
+
timestamp=selected.timestamp,
|
|
349
|
+
used_no_id_fallback=selected.used_no_id_fallback,
|
|
350
|
+
)
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
counters["selected_candidates"] = len(selections)
|
|
354
|
+
stable_totals = {key: totals[key] for key in sorted(totals) if totals[key]}
|
|
355
|
+
stable_by_model = {
|
|
356
|
+
model: {key: buckets[key] for key in sorted(buckets) if buckets[key]}
|
|
357
|
+
for model, buckets in sorted(by_model.items())
|
|
358
|
+
if any(buckets.values())
|
|
359
|
+
}
|
|
360
|
+
return UsageReduction(
|
|
361
|
+
schema=REDUCER_SCHEMA,
|
|
362
|
+
tokens=stable_totals,
|
|
363
|
+
by_model=stable_by_model,
|
|
364
|
+
counters={key: counters.get(key, 0) for key in COUNTER_KEYS},
|
|
365
|
+
partial=partial,
|
|
366
|
+
selections=tuple(selections),
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def reduce_rows(
|
|
371
|
+
rows: Iterable[Any],
|
|
372
|
+
*,
|
|
373
|
+
file_identity: str,
|
|
374
|
+
start_ordinal: int = 0,
|
|
375
|
+
) -> UsageReduction:
|
|
376
|
+
reducer = UsageReducer()
|
|
377
|
+
reducer.extend(rows, file_identity=file_identity, start_ordinal=start_ordinal)
|
|
378
|
+
return reducer.finalize()
|