@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.
Files changed (29) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.ko.md +46 -1
  3. package/README.md +58 -2
  4. package/package.json +1 -1
  5. package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
  6. package/plugins/context-guard/README.ko.md +18 -0
  7. package/plugins/context-guard/README.md +18 -0
  8. package/plugins/context-guard/bin/context-guard-artifact +90 -9
  9. package/plugins/context-guard/bin/context-guard-audit +169 -66
  10. package/plugins/context-guard/bin/context-guard-bench +5765 -224
  11. package/plugins/context-guard/bin/context-guard-compress +90 -8
  12. package/plugins/context-guard/bin/context-guard-diet +1 -7
  13. package/plugins/context-guard/bin/context-guard-experiments +5 -1
  14. package/plugins/context-guard/bin/context-guard-failed-nudge +705 -83
  15. package/plugins/context-guard/bin/context-guard-guard-read +490 -55
  16. package/plugins/context-guard/bin/context-guard-pack +110 -11
  17. package/plugins/context-guard/bin/context-guard-read-symbol +7 -2
  18. package/plugins/context-guard/bin/context-guard-rewrite-bash +2204 -223
  19. package/plugins/context-guard/bin/context-guard-sanitize-output +560 -85
  20. package/plugins/context-guard/bin/context-guard-setup +1073 -147
  21. package/plugins/context-guard/bin/context-guard-statusline +131 -54
  22. package/plugins/context-guard/bin/context-guard-statusline-merged +7 -3
  23. package/plugins/context-guard/bin/context-guard-tool-prune +44 -11
  24. package/plugins/context-guard/bin/context-guard-trim-output +89 -13
  25. package/plugins/context-guard/brief/README.md +19 -0
  26. package/plugins/context-guard/brief/narration-mode.quiet.md +21 -0
  27. package/plugins/context-guard/lib/context_guard_commands.py +6 -2
  28. package/plugins/context-guard/lib/credential_policy.py +177 -0
  29. 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()