cctally 1.115.0 → 1.116.0
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 +16 -0
- package/bin/_cctally_cache.py +85 -4
- package/bin/_cctally_completion.py +80 -12
- package/bin/_cctally_core.py +106 -9
- package/bin/_cctally_dashboard.py +6 -9
- package/bin/_cctally_dashboard_envelope.py +26 -32
- package/bin/_cctally_db.py +85 -4
- package/bin/_cctally_doctor.py +38 -5
- package/bin/_cctally_five_hour.py +83 -1017
- package/bin/_cctally_journal.py +1307 -180
- package/bin/_cctally_journal_repair.py +9 -7
- package/bin/_cctally_oauth_samples.py +280 -0
- package/bin/_cctally_parser.py +5 -0
- package/bin/_cctally_quarantine.py +14 -4
- package/bin/_cctally_quota.py +70 -8
- package/bin/_cctally_quota_model.py +35 -4
- package/bin/_cctally_record.py +414 -123
- package/bin/_cctally_rederive.py +632 -235
- package/bin/_cctally_refresh.py +29 -0
- package/bin/_cctally_statusline.py +984 -47
- package/bin/_cctally_store.py +972 -58
- package/bin/_cctally_tui.py +62 -7
- package/bin/_cctally_update.py +25 -0
- package/bin/_lib_account_protocol.py +201 -18
- package/bin/_lib_clone_copy.py +46 -11
- package/bin/_lib_dashboard_sources.py +57 -15
- package/bin/_lib_detector_access_registry.py +273 -52
- package/bin/_lib_doctor.py +302 -1
- package/bin/_lib_journal.py +145 -25
- package/bin/_lib_journal_router.py +166 -48
- package/bin/_lib_oauth_samples.py +238 -0
- package/bin/_lib_pricing_check.py +0 -12
- package/bin/_lib_provider_activity.py +770 -0
- package/bin/_lib_quarantine.py +3 -2
- package/bin/_lib_quota_model.py +96 -6
- package/bin/_lib_rebuild_progress.py +146 -0
- package/bin/_lib_record.py +22 -0
- package/bin/_lib_rederive.py +299 -3
- package/bin/_lib_snapshot_cache.py +43 -1
- package/bin/_lib_stale_proof.py +708 -0
- package/bin/_lib_statusline_candidates.py +65 -5
- package/bin/_lib_worker_launches.py +315 -0
- package/bin/cctally +6 -11
- package/dashboard/static/assets/{ConversationsView-Bc8LCOAZ.js → ConversationsView-Dflb_myA.js} +1 -1
- package/dashboard/static/assets/DoctorModal-DFX5CfIs.js +1 -0
- package/dashboard/static/assets/{ModalRoot-DLPgyAM_.js → ModalRoot-HlxoagIa.js} +1 -1
- package/dashboard/static/assets/{ProjectsDrillPanel-z53a4v4M.js → ProjectsDrillPanel-C3PJy1xx.js} +1 -1
- package/dashboard/static/assets/{SourceDetailModal-Bw0T8W-W.js → SourceDetailModal-BczsWKhD.js} +1 -1
- package/dashboard/static/assets/{UpdateModal-D5AKx4Yw.js → UpdateModal-Os-Lo5cs.js} +1 -1
- package/dashboard/static/assets/{index-BXDOzSng.js → index-CYZ3NLqF.js} +3 -3
- package/dashboard/static/assets/{outlineNavigation-JObY7cUY.js → outlineNavigation-k-zIif6W.js} +1 -1
- package/dashboard/static/dashboard.html +1 -1
- package/package.json +7 -2
- package/bin/_lib_merge_gate.py +0 -452
- package/dashboard/static/assets/DoctorModal-D--qVBwe.js +0 -1
|
@@ -155,12 +155,37 @@ class PrefixHashAccumulator:
|
|
|
155
155
|
|
|
156
156
|
The framing is `journal_prefix_hash`'s, verbatim: per segment, the 4-byte
|
|
157
157
|
big-endian name length, the name, the 8-byte big-endian data length, then
|
|
158
|
-
the data.
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
158
|
+
the data. The data length precedes the data, so `begin_segment` takes the
|
|
159
|
+
exact byte count the pass will feed (`size`) and frames the segment header
|
|
160
|
+
with it; every chunk is then hashed as it arrives and NO segment bytes are
|
|
161
|
+
retained (#997 D12). The buffered form held the whole current segment in a
|
|
162
|
+
`bytearray` grown by `extend` — 410 MB on the maintainer's largest segment
|
|
163
|
+
— and the freed growth steps stayed resident in the macOS allocator.
|
|
164
|
+
|
|
165
|
+
A digest at a fully fed segment end (every transition, and every boundary
|
|
166
|
+
not short of a torn tail) comes from the streamed state. A digest at any
|
|
167
|
+
other offset — an evidence point inside the current segment, a boundary
|
|
168
|
+
short of a torn trailing line, a feed that ended short of its declared
|
|
169
|
+
size — re-reads the segment through `open_segment(name)` in 1 MiB chunks,
|
|
170
|
+
which the production callers bind to the journal's one segment-read seam,
|
|
171
|
+
so an evidence re-read is visible to it.
|
|
172
|
+
|
|
173
|
+
Consumed extent (#997 D12, revision 10). Per segment the accumulator also
|
|
174
|
+
tracks the bytes actually fed (`consumed`) and a raw SHA-256 of those bytes
|
|
175
|
+
alone (the witness). An offset beyond `consumed` raises. Every re-read reads
|
|
176
|
+
through `consumed`, not only through the requested offset: one pass frames
|
|
177
|
+
the prefix digest at the offset and hashes `[0, consumed)` raw, and the
|
|
178
|
+
digest is returned only when the read was not short and that raw hash
|
|
179
|
+
equals the witness. A torn tail an appender truncated and replaced after
|
|
180
|
+
the pass read it, or a line the reader assembled from old and replacement
|
|
181
|
+
bytes across two reads, can therefore never be certified. A registered
|
|
182
|
+
boundary keeps its segment's `consumed` and witness with its saved hash
|
|
183
|
+
state. A segment fed short of its declared size is re-framed from its
|
|
184
|
+
consumed bytes through the same verified re-read at the next
|
|
185
|
+
`begin_segment`; when that re-read is unavailable or fails verification
|
|
186
|
+
the accumulator is poisoned and every later digest raises. A missing
|
|
187
|
+
opener, a short re-read or a failed verification raises
|
|
188
|
+
`PrefixEvidenceUnavailable`.
|
|
164
189
|
"""
|
|
165
190
|
|
|
166
191
|
# A transition can only ever register one boundary, and consecutive empty
|
|
@@ -176,10 +201,28 @@ class PrefixHashAccumulator:
|
|
|
176
201
|
# entry that is still live.
|
|
177
202
|
_MAX_BOUNDARIES = 16
|
|
178
203
|
|
|
179
|
-
|
|
204
|
+
#: Chunk size of an evidence re-read through the opener.
|
|
205
|
+
_REREAD_CHUNK = 1 << 20
|
|
206
|
+
|
|
207
|
+
def __init__(self, *, open_segment=None) -> None:
|
|
208
|
+
self._open_segment = open_segment
|
|
209
|
+
#: The hash of every segment before the current one, absorbed.
|
|
180
210
|
self._running = hashlib.sha256()
|
|
181
211
|
self._current_name = None
|
|
182
|
-
|
|
212
|
+
#: `_running` plus the current segment's header (framed with its
|
|
213
|
+
#: declared size) and every byte fed so far.
|
|
214
|
+
self._segment = None
|
|
215
|
+
self._declared = 0
|
|
216
|
+
#: Bytes of the current segment actually fed (its consumed extent),
|
|
217
|
+
#: and the raw SHA-256 of exactly those bytes (the witness).
|
|
218
|
+
self._fed = 0
|
|
219
|
+
self._witness = hashlib.sha256()
|
|
220
|
+
#: Set when a short segment could not be re-framed from verified
|
|
221
|
+
#: bytes: the running state no longer describes the journal.
|
|
222
|
+
self._poisoned = None
|
|
223
|
+
#: `(segment, offset) -> (state before that segment, final digest or
|
|
224
|
+
#: None, consumed, witness digest)`; a `None` final digest is re-read
|
|
225
|
+
#: through the opener, verified against the witness, when read.
|
|
183
226
|
self._boundaries: dict = {}
|
|
184
227
|
self._boundary_order: list = []
|
|
185
228
|
#: Bytes fed into an evidence digest, reported as the `protocol_evidence`
|
|
@@ -189,55 +232,92 @@ class PrefixHashAccumulator:
|
|
|
189
232
|
|
|
190
233
|
# -- feeding ----------------------------------------------------------
|
|
191
234
|
|
|
192
|
-
def begin_segment(self, name: str, boundary=None) -> None:
|
|
193
|
-
"""Start `name`,
|
|
235
|
+
def begin_segment(self, name: str, boundary=None, *, size: int) -> None:
|
|
236
|
+
"""Start `name`, which the pass will feed `size` bytes of, absorbing
|
|
237
|
+
whatever segment was open before it.
|
|
194
238
|
|
|
195
239
|
`boundary` is the `(segment, offset)` the next record's evidence would
|
|
196
|
-
name — i.e. the streaming loop's current `prior_high_water`.
|
|
197
|
-
|
|
198
|
-
immediately afterwards.
|
|
240
|
+
name — i.e. the streaming loop's current `prior_high_water`. Its hash
|
|
241
|
+
state is kept HERE because the outgoing segment's state is folded into
|
|
242
|
+
the running hash immediately afterwards.
|
|
199
243
|
"""
|
|
200
244
|
if boundary is not None:
|
|
201
245
|
self._register_boundary(boundary)
|
|
202
246
|
if self._current_name is not None:
|
|
203
|
-
|
|
204
|
-
# segment is framed WITHOUT a copy of itself. The maintainer's
|
|
205
|
-
# largest segment is 410 MB, and copying it here briefly doubled
|
|
206
|
-
# that at every transition.
|
|
207
|
-
with memoryview(self._current) as data:
|
|
208
|
-
self._absorb(self._current_name, data)
|
|
247
|
+
self._running = self._completed_segment()
|
|
209
248
|
self._current_name = name
|
|
210
|
-
self.
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
self.
|
|
249
|
+
self._declared = int(size)
|
|
250
|
+
self._fed = 0
|
|
251
|
+
self._witness = hashlib.sha256()
|
|
252
|
+
self._segment = self._running.copy()
|
|
253
|
+
self._header(self._segment, name, self._declared)
|
|
254
|
+
|
|
255
|
+
def extend(self, data) -> None:
|
|
256
|
+
"""Hash raw bytes read from the segment currently open."""
|
|
257
|
+
self._segment.update(data)
|
|
258
|
+
self._witness.update(data)
|
|
259
|
+
self._fed += len(data)
|
|
215
260
|
|
|
216
261
|
# -- reading ----------------------------------------------------------
|
|
217
262
|
|
|
218
263
|
def digest_at(self, high_water) -> "str | None":
|
|
219
|
-
"""`journal_prefix_hash(high_water)`, from the streamed
|
|
264
|
+
"""`journal_prefix_hash(high_water)`, from the streamed state."""
|
|
220
265
|
if high_water is None:
|
|
221
266
|
return None
|
|
222
267
|
segment, offset = high_water
|
|
223
|
-
if
|
|
224
|
-
digest = self._running.copy()
|
|
225
|
-
with memoryview(self._current)[:offset] as data:
|
|
226
|
-
self._frame(digest, segment, data)
|
|
227
|
-
self.bytes_hashed += len(data)
|
|
228
|
-
self.digests_computed += 1
|
|
229
|
-
return "sha256:" + digest.hexdigest()
|
|
230
|
-
cached = self._boundaries.get((segment, offset))
|
|
231
|
-
if cached is None:
|
|
268
|
+
if self._poisoned is not None:
|
|
232
269
|
raise PrefixEvidenceUnavailable(
|
|
233
|
-
f"no
|
|
270
|
+
f"no verified prefix evidence for {segment}@{offset}: "
|
|
271
|
+
f"{self._poisoned}"
|
|
234
272
|
)
|
|
235
|
-
self.
|
|
273
|
+
if segment == self._current_name:
|
|
274
|
+
if self._streamed_end(offset):
|
|
275
|
+
digest = self._segment.copy()
|
|
276
|
+
else:
|
|
277
|
+
digest = self._verified_rehash(
|
|
278
|
+
self._running, segment, offset, self._fed,
|
|
279
|
+
self._witness.digest())
|
|
280
|
+
else:
|
|
281
|
+
cached = self._boundaries.get((segment, offset))
|
|
282
|
+
if cached is None:
|
|
283
|
+
raise PrefixEvidenceUnavailable(
|
|
284
|
+
f"no streamed prefix evidence for {segment}@{offset}"
|
|
285
|
+
)
|
|
286
|
+
before, final, consumed, witness = cached
|
|
287
|
+
digest = (final.copy() if final is not None
|
|
288
|
+
else self._verified_rehash(
|
|
289
|
+
before, segment, offset, consumed, witness))
|
|
290
|
+
self.bytes_hashed += offset
|
|
236
291
|
self.digests_computed += 1
|
|
237
|
-
return
|
|
292
|
+
return "sha256:" + digest.hexdigest()
|
|
238
293
|
|
|
239
294
|
# -- internals --------------------------------------------------------
|
|
240
295
|
|
|
296
|
+
def _streamed_end(self, offset) -> bool:
|
|
297
|
+
"""Whether the streamed state IS the digest at `offset`: the current
|
|
298
|
+
segment was fed exactly its declared size and `offset` is its end."""
|
|
299
|
+
return offset == self._fed == self._declared
|
|
300
|
+
|
|
301
|
+
def _completed_segment(self):
|
|
302
|
+
"""The running hash with the current segment absorbed as fed."""
|
|
303
|
+
if self._fed == self._declared or self._poisoned is not None:
|
|
304
|
+
return self._segment
|
|
305
|
+
# The feed ended short of the declared size (the segment shrank under
|
|
306
|
+
# the pass): frame the bytes actually fed, as the buffered form did,
|
|
307
|
+
# from a re-read verified against them. Bytes that cannot be verified
|
|
308
|
+
# leave no running state that describes the journal, so every later
|
|
309
|
+
# digest raises rather than certifying one.
|
|
310
|
+
try:
|
|
311
|
+
return self._verified_rehash(
|
|
312
|
+
self._running, self._current_name, self._fed, self._fed,
|
|
313
|
+
self._witness.digest())
|
|
314
|
+
except (PrefixEvidenceUnavailable, OSError) as exc:
|
|
315
|
+
self._poisoned = (
|
|
316
|
+
f"short segment {self._current_name} could not be re-framed "
|
|
317
|
+
f"from its consumed bytes ({exc})"
|
|
318
|
+
)
|
|
319
|
+
return self._segment
|
|
320
|
+
|
|
241
321
|
def _register_boundary(self, boundary) -> None:
|
|
242
322
|
segment, offset = boundary
|
|
243
323
|
if segment != self._current_name:
|
|
@@ -247,23 +327,61 @@ class PrefixHashAccumulator:
|
|
|
247
327
|
key = (segment, offset)
|
|
248
328
|
if key in self._boundaries:
|
|
249
329
|
return
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
self._boundaries[key] = ("sha256:" + digest.hexdigest(), size)
|
|
330
|
+
final = (self._segment.copy() if self._streamed_end(offset)
|
|
331
|
+
else None)
|
|
332
|
+
self._boundaries[key] = (self._running.copy(), final, self._fed,
|
|
333
|
+
self._witness.digest())
|
|
255
334
|
self._boundary_order.append(key)
|
|
256
335
|
while len(self._boundary_order) > self._MAX_BOUNDARIES:
|
|
257
336
|
self._boundaries.pop(self._boundary_order.pop(0), None)
|
|
258
337
|
|
|
259
|
-
def
|
|
260
|
-
|
|
338
|
+
def _verified_rehash(self, before, name: str, offset: int,
|
|
339
|
+
consumed: int, witness: bytes):
|
|
340
|
+
"""`before` plus `name`'s first `offset` bytes, re-read from the
|
|
341
|
+
segment through the opener in `_REREAD_CHUNK` pieces.
|
|
342
|
+
|
|
343
|
+
The read always runs through `consumed`, the bytes the pass fed, and
|
|
344
|
+
its raw SHA-256 must equal `witness`; only then are the bytes the
|
|
345
|
+
digest frames the bytes the pass read."""
|
|
346
|
+
if offset > consumed:
|
|
347
|
+
raise PrefixEvidenceUnavailable(
|
|
348
|
+
f"prefix evidence {name}@{offset} lies beyond the "
|
|
349
|
+
f"{consumed} bytes the pass consumed"
|
|
350
|
+
)
|
|
351
|
+
digest = before.copy()
|
|
352
|
+
self._header(digest, name, offset)
|
|
353
|
+
if consumed <= 0:
|
|
354
|
+
return digest
|
|
355
|
+
if self._open_segment is None:
|
|
356
|
+
raise PrefixEvidenceUnavailable(
|
|
357
|
+
f"no segment opener to re-read prefix evidence for "
|
|
358
|
+
f"{name}@{offset}"
|
|
359
|
+
)
|
|
360
|
+
raw = hashlib.sha256()
|
|
361
|
+
pos = 0
|
|
362
|
+
with self._open_segment(name) as handle:
|
|
363
|
+
while pos < consumed:
|
|
364
|
+
chunk = handle.read(min(self._REREAD_CHUNK, consumed - pos))
|
|
365
|
+
if not chunk:
|
|
366
|
+
raise PrefixEvidenceUnavailable(
|
|
367
|
+
f"short re-read of prefix evidence for {name}@{offset}"
|
|
368
|
+
)
|
|
369
|
+
raw.update(chunk)
|
|
370
|
+
if pos < offset:
|
|
371
|
+
with memoryview(chunk)[:offset - pos] as framed:
|
|
372
|
+
digest.update(framed)
|
|
373
|
+
pos += len(chunk)
|
|
374
|
+
if raw.digest() != witness:
|
|
375
|
+
raise PrefixEvidenceUnavailable(
|
|
376
|
+
f"segment {name} no longer holds the {consumed} bytes the "
|
|
377
|
+
f"pass consumed; prefix evidence {name}@{offset} is not "
|
|
378
|
+
f"certified"
|
|
379
|
+
)
|
|
380
|
+
return digest
|
|
261
381
|
|
|
262
382
|
@staticmethod
|
|
263
|
-
def
|
|
264
|
-
"""`data` is any bytes-like buffer; `hashlib` consumes it in place."""
|
|
383
|
+
def _header(digest, name: str, length: int) -> None:
|
|
265
384
|
encoded = name.encode("utf-8")
|
|
266
385
|
digest.update(len(encoded).to_bytes(4, "big"))
|
|
267
386
|
digest.update(encoded)
|
|
268
|
-
digest.update(
|
|
269
|
-
digest.update(data)
|
|
387
|
+
digest.update(int(length).to_bytes(8, "big"))
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Provider samples: the raw api observation as an OAuth sample (#797/#823/#840
|
|
2
|
+
spec 4.3, D8 and D9).
|
|
3
|
+
|
|
4
|
+
Pure-fn layer (no I/O, no sibling imports). Every OAuth fetch this binary
|
|
5
|
+
performs records ``q``, the wall-clock instant immediately before the request
|
|
6
|
+
starts, and publishes it as the additive raw api ``obs`` payload key
|
|
7
|
+
``oauth_request_started_at`` (:data:`REQUEST_STARTED_KEY`). The provider
|
|
8
|
+
sampled the response at some instant at or after ``q`` (assumption A-Q), so a
|
|
9
|
+
sample whose ``q`` is after a contributor's provider-activity instant is a
|
|
10
|
+
reading the contributor could not have seen. This module turns one raw
|
|
11
|
+
payload into one :class:`OAuthSample` — the row shape of the stats table
|
|
12
|
+
``claude_oauth_samples`` — and states window identity once.
|
|
13
|
+
|
|
14
|
+
Values are the RAW payload values, never the MAX-clamped stored snapshot
|
|
15
|
+
values: the weekly percent and its reset (nearest-hour normalized exactly as
|
|
16
|
+
the week-boundary code does), the five-hour percent and its raw reset, and the
|
|
17
|
+
authoritative five-hour window key. That key is the producer's frozen planned
|
|
18
|
+
key when the payload carries a valid one (an OAuth publication always does
|
|
19
|
+
when it carries five-hour fields), else the pure canonical floor; a planned
|
|
20
|
+
key that contradicts its own raw reset is conflicting identity and yields no
|
|
21
|
+
key, so it can never supply a five-hour proof.
|
|
22
|
+
|
|
23
|
+
Instants: ``requested_at_utc`` is stored in one canonical full-precision form
|
|
24
|
+
(``YYYY-MM-DDTHH:MM:SS.ffffffZ``), so lexicographic order is chronological and
|
|
25
|
+
a strict ``q > t`` comparison is exact. ``captured_at_utc`` is the payload's
|
|
26
|
+
capture stamp verbatim (seconds precision, truncated): for an integral reset
|
|
27
|
+
``R``, ``captured < R`` holds exactly when the true completion instant is
|
|
28
|
+
before ``R``, so the truncation never weakens the reset bound.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import dataclasses
|
|
33
|
+
import datetime as dt
|
|
34
|
+
import math
|
|
35
|
+
import re
|
|
36
|
+
|
|
37
|
+
#: The raw api observation payload key carrying ``q``.
|
|
38
|
+
REQUEST_STARTED_KEY = "oauth_request_started_at"
|
|
39
|
+
#: The one source an OAuth publication records.
|
|
40
|
+
API_SOURCE = "api"
|
|
41
|
+
#: `_lib_five_hour._FIVE_HOUR_JITTER_FLOOR_SECONDS`, restated because this
|
|
42
|
+
#: module imports no sibling; `tests/test_oauth_sample_projection_797.py`
|
|
43
|
+
#: pins the equality.
|
|
44
|
+
FIVE_HOUR_JITTER_FLOOR_SECONDS = 600
|
|
45
|
+
#: The tolerance a planned five-hour key may sit from its raw reset — the
|
|
46
|
+
#: same bound `_pipeline_claude_usage` validates a planned key against.
|
|
47
|
+
PLANNED_KEY_TOLERANCE_SECONDS = FIVE_HOUR_JITTER_FLOOR_SECONDS * 3
|
|
48
|
+
#: The capture stamp is truncated to the second, so a genuine ``q`` can sit
|
|
49
|
+
#: up to one second after it.
|
|
50
|
+
_CAPTURE_TRUNCATION_SECONDS = 1
|
|
51
|
+
|
|
52
|
+
_REQUEST_STARTED = re.compile(
|
|
53
|
+
r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d{1,6})?Z$")
|
|
54
|
+
_CANONICAL_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ"
|
|
55
|
+
_SECONDS_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclasses.dataclass(frozen=True)
|
|
59
|
+
class OAuthSample:
|
|
60
|
+
"""One provider sample: a row of ``claude_oauth_samples``."""
|
|
61
|
+
|
|
62
|
+
account_key: str
|
|
63
|
+
observation_id: str
|
|
64
|
+
requested_at_utc: str
|
|
65
|
+
captured_at_utc: str
|
|
66
|
+
weekly_percent: "float | None"
|
|
67
|
+
weekly_resets_at_utc: "str | None"
|
|
68
|
+
five_hour_percent: "float | None"
|
|
69
|
+
five_hour_resets_at_utc: "str | None"
|
|
70
|
+
five_hour_window_key: "int | None"
|
|
71
|
+
|
|
72
|
+
def row(self) -> tuple:
|
|
73
|
+
"""The column values in table order."""
|
|
74
|
+
return tuple(getattr(self, f.name) for f in dataclasses.fields(self))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def format_request_started(instant: dt.datetime) -> str:
|
|
78
|
+
"""``instant`` (tz-aware) in the carrier's canonical full-precision UTC
|
|
79
|
+
form. A naive datetime is a programming error and raises."""
|
|
80
|
+
if instant.tzinfo is None:
|
|
81
|
+
raise ValueError("the request-start instant must be timezone-aware")
|
|
82
|
+
return instant.astimezone(dt.timezone.utc).strftime(_CANONICAL_FORMAT)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _parse_aware(value) -> "dt.datetime | None":
|
|
86
|
+
if not isinstance(value, str) or not value:
|
|
87
|
+
return None
|
|
88
|
+
try:
|
|
89
|
+
parsed = dt.datetime.fromisoformat(
|
|
90
|
+
value[:-1] + "+00:00" if value.endswith("Z") else value)
|
|
91
|
+
except ValueError:
|
|
92
|
+
return None
|
|
93
|
+
if parsed.tzinfo is None:
|
|
94
|
+
return None
|
|
95
|
+
return parsed.astimezone(dt.timezone.utc)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def canonical_instant(value) -> str:
|
|
99
|
+
"""Any tz-aware ISO-8601 instant (or tz-aware datetime) in the canonical
|
|
100
|
+
full-precision form used for ``requested_at_utc``; raises ``ValueError``
|
|
101
|
+
on anything else. Callers compare their own instants against stored
|
|
102
|
+
``q`` values through this form."""
|
|
103
|
+
if isinstance(value, dt.datetime):
|
|
104
|
+
return format_request_started(value)
|
|
105
|
+
parsed = _parse_aware(value)
|
|
106
|
+
if parsed is None:
|
|
107
|
+
raise ValueError(f"not a timezone-aware ISO-8601 instant: {value!r}")
|
|
108
|
+
return parsed.strftime(_CANONICAL_FORMAT)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def parse_request_started(value) -> "str | None":
|
|
112
|
+
"""The canonical ``q`` of a raw payload value, or None when it is missing
|
|
113
|
+
or unparsable. Only the carrier's own shape is accepted (UTC with ``Z``,
|
|
114
|
+
up to microsecond precision); a bool, a number or a naive instant is not
|
|
115
|
+
a key."""
|
|
116
|
+
if not isinstance(value, str) or not _REQUEST_STARTED.match(value):
|
|
117
|
+
return None
|
|
118
|
+
parsed = _parse_aware(value)
|
|
119
|
+
return None if parsed is None else parsed.strftime(_CANONICAL_FORMAT)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def normalize_weekly_reset(resets_at_epoch: int) -> str:
|
|
123
|
+
"""The weekly window identity of a raw ``resets_at`` epoch: rounded to the
|
|
124
|
+
nearest hour exactly as `_cctally_core._normalize_week_boundary_dt` does
|
|
125
|
+
(minutes 0..29 down, 30..59 up), as ``YYYY-MM-DDTHH:00:00Z``."""
|
|
126
|
+
value = dt.datetime.fromtimestamp(int(resets_at_epoch), tz=dt.timezone.utc)
|
|
127
|
+
normalized = value.replace(minute=0, second=0, microsecond=0)
|
|
128
|
+
if value.minute >= 30:
|
|
129
|
+
normalized += dt.timedelta(hours=1)
|
|
130
|
+
return normalized.strftime(_SECONDS_FORMAT)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def weekly_window(window) -> str:
|
|
134
|
+
"""A weekly window argument (a raw or normalized reset as an epoch int or
|
|
135
|
+
a tz-aware ISO instant) as its normalized identity."""
|
|
136
|
+
if isinstance(window, bool):
|
|
137
|
+
raise ValueError("a weekly window is a reset instant, not a bool")
|
|
138
|
+
if isinstance(window, int):
|
|
139
|
+
return normalize_weekly_reset(window)
|
|
140
|
+
parsed = _parse_aware(window)
|
|
141
|
+
if parsed is None:
|
|
142
|
+
raise ValueError(f"not a weekly reset instant: {window!r}")
|
|
143
|
+
return normalize_weekly_reset(int(parsed.timestamp()))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def five_hour_window_key(resets_at_epoch: int, planned) -> "int | None":
|
|
147
|
+
"""The authoritative five-hour window key: the producer's planned key
|
|
148
|
+
when it is an int within the planned-key tolerance of the raw reset,
|
|
149
|
+
None when a planned key is present but contradicts it, else the pure
|
|
150
|
+
canonical floor (`_canonical_5h_window_key` without an anchor)."""
|
|
151
|
+
if planned is not None:
|
|
152
|
+
if (type(planned) is not int
|
|
153
|
+
or abs(planned - resets_at_epoch)
|
|
154
|
+
> PLANNED_KEY_TOLERANCE_SECONDS):
|
|
155
|
+
return None
|
|
156
|
+
return planned
|
|
157
|
+
return resets_at_epoch - (resets_at_epoch % FIVE_HOUR_JITTER_FLOOR_SECONDS)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _percent(value) -> "float | None":
|
|
161
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
162
|
+
return None
|
|
163
|
+
value = float(value)
|
|
164
|
+
return value if math.isfinite(value) else None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _bound_account(payload: dict) -> "str | None":
|
|
168
|
+
binding = payload.get("account_binding")
|
|
169
|
+
if not isinstance(binding, dict):
|
|
170
|
+
return None
|
|
171
|
+
key = binding.get("accountKey")
|
|
172
|
+
return key if isinstance(key, str) and key else None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def sample_from_observation(payload: dict, *, account_key: str,
|
|
176
|
+
observation_id: str,
|
|
177
|
+
captured_at_utc: str) -> "OAuthSample | None":
|
|
178
|
+
"""The provider sample of one raw Claude usage observation payload, or
|
|
179
|
+
None: for a non-api source, a missing or unparsable request-start key, a
|
|
180
|
+
request instant after the capture, a missing or unparsable capture
|
|
181
|
+
instant, or no Claude account binding naming ``account_key``.
|
|
182
|
+
|
|
183
|
+
Each axis is independent: an unparsable weekly or five-hour field leaves
|
|
184
|
+
that axis's columns None (no identity, so no proof on that axis), and a
|
|
185
|
+
sample with neither axis is no sample."""
|
|
186
|
+
if not isinstance(payload, dict) or payload.get("source") != API_SOURCE:
|
|
187
|
+
return None
|
|
188
|
+
if not isinstance(account_key, str) or not account_key:
|
|
189
|
+
return None
|
|
190
|
+
if not isinstance(observation_id, str) or not observation_id:
|
|
191
|
+
return None
|
|
192
|
+
if _bound_account(payload) != account_key:
|
|
193
|
+
return None
|
|
194
|
+
requested = parse_request_started(payload.get(REQUEST_STARTED_KEY))
|
|
195
|
+
captured = _parse_aware(captured_at_utc)
|
|
196
|
+
if requested is None or captured is None:
|
|
197
|
+
return None
|
|
198
|
+
requested_dt = _parse_aware(requested)
|
|
199
|
+
if (requested_dt - captured).total_seconds() > _CAPTURE_TRUNCATION_SECONDS:
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
weekly_percent = _percent(payload.get("weekly_percent"))
|
|
203
|
+
weekly_reset = None
|
|
204
|
+
resets_at = payload.get("resets_at")
|
|
205
|
+
if not isinstance(resets_at, bool):
|
|
206
|
+
try:
|
|
207
|
+
weekly_reset = normalize_weekly_reset(int(resets_at))
|
|
208
|
+
except (TypeError, ValueError, OverflowError, OSError):
|
|
209
|
+
weekly_reset = None
|
|
210
|
+
if weekly_reset is None:
|
|
211
|
+
weekly_percent = None
|
|
212
|
+
|
|
213
|
+
five_percent = _percent(payload.get("five_hour_percent"))
|
|
214
|
+
five_reset = None
|
|
215
|
+
five_key = None
|
|
216
|
+
five_reset_dt = _parse_aware(payload.get("five_hour_resets_at"))
|
|
217
|
+
if five_reset_dt is not None:
|
|
218
|
+
five_reset = five_reset_dt.strftime(_SECONDS_FORMAT)
|
|
219
|
+
five_key = five_hour_window_key(
|
|
220
|
+
int(five_reset_dt.timestamp()),
|
|
221
|
+
payload.get("five_hour_window_key"))
|
|
222
|
+
if five_percent is None:
|
|
223
|
+
five_reset = None
|
|
224
|
+
five_key = None
|
|
225
|
+
|
|
226
|
+
if weekly_percent is None and five_percent is None:
|
|
227
|
+
return None
|
|
228
|
+
return OAuthSample(
|
|
229
|
+
account_key=account_key,
|
|
230
|
+
observation_id=observation_id,
|
|
231
|
+
requested_at_utc=requested,
|
|
232
|
+
captured_at_utc=captured_at_utc,
|
|
233
|
+
weekly_percent=weekly_percent,
|
|
234
|
+
weekly_resets_at_utc=weekly_reset,
|
|
235
|
+
five_hour_percent=five_percent,
|
|
236
|
+
five_hour_resets_at_utc=five_reset,
|
|
237
|
+
five_hour_window_key=five_key,
|
|
238
|
+
)
|
|
@@ -232,15 +232,3 @@ def check_table_shapes(claude_tbl, codex_tbl, zero_sentinels) -> list:
|
|
|
232
232
|
for model, body in codex_tbl.items():
|
|
233
233
|
_check(model, body, _CODEX_REQUIRED, allow_zero=model in zero_sentinels)
|
|
234
234
|
return problems
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
def pricing_issue_action(findings_present: bool, existing_open: bool) -> str:
|
|
238
|
-
"""Decide the cron's GitHub-issue action. Pure; the YAML executes it.
|
|
239
|
-
|
|
240
|
-
``findings_present`` is ANY actionable finding the cron tracks — value
|
|
241
|
-
drift, missing-from-us, OR (as of #279 S7 W7) a stale/expired allowlist
|
|
242
|
-
suppression. The name generalizes the former ``drift_present``; the
|
|
243
|
-
two-state machine is unchanged."""
|
|
244
|
-
if findings_present:
|
|
245
|
-
return "update" if existing_open else "create"
|
|
246
|
-
return "close" if existing_open else "noop"
|