cctally 1.67.1 → 1.69.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 +68 -0
- package/bin/_cctally_alerts.py +54 -2
- package/bin/_cctally_cache.py +754 -109
- package/bin/_cctally_cache_report.py +41 -17
- package/bin/_cctally_codex.py +109 -4
- package/bin/_cctally_config.py +368 -2
- package/bin/_cctally_core.py +187 -15
- package/bin/_cctally_dashboard.py +679 -5
- package/bin/_cctally_dashboard_conversation.py +9 -4
- package/bin/_cctally_dashboard_envelope.py +110 -1
- package/bin/_cctally_dashboard_share.py +557 -79
- package/bin/_cctally_db.py +366 -17
- package/bin/_cctally_diff.py +49 -16
- package/bin/_cctally_doctor.py +131 -0
- package/bin/_cctally_forecast.py +12 -4
- package/bin/_cctally_parser.py +321 -92
- package/bin/_cctally_project.py +24 -8
- package/bin/_cctally_quota.py +1381 -0
- package/bin/_cctally_record.py +319 -14
- package/bin/_cctally_refresh.py +105 -3
- package/bin/_cctally_reporting.py +7 -1
- package/bin/_cctally_setup.py +343 -113
- package/bin/_cctally_statusline.py +228 -0
- package/bin/_cctally_tui.py +787 -7
- package/bin/_lib_alert_axes.py +11 -0
- package/bin/_lib_codex_hooks.py +367 -0
- package/bin/_lib_conversation_query.py +156 -67
- package/bin/_lib_doctor.py +238 -0
- package/bin/_lib_jsonl.py +529 -162
- package/bin/_lib_quota.py +566 -0
- package/bin/_lib_share.py +152 -34
- package/bin/_lib_snapshot_cache.py +26 -0
- package/bin/_lib_source_identity.py +74 -0
- package/bin/cctally +325 -10
- package/dashboard/static/assets/index-CBbErI-P.js +80 -0
- package/dashboard/static/assets/index-kDDVOLa_.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +5 -1
- package/dashboard/static/assets/index-BybNp_Di.js +0 -80
- package/dashboard/static/assets/index-DgAmLK65.css +0 -1
package/bin/_lib_jsonl.py
CHANGED
|
@@ -22,12 +22,15 @@ from __future__ import annotations
|
|
|
22
22
|
|
|
23
23
|
import datetime as dt
|
|
24
24
|
import json
|
|
25
|
+
import math
|
|
25
26
|
import pathlib
|
|
26
27
|
import re
|
|
27
28
|
import sys
|
|
28
29
|
from dataclasses import dataclass, field
|
|
29
30
|
from typing import Any
|
|
30
31
|
|
|
32
|
+
from _lib_source_identity import canonical_identity_from_root_key
|
|
33
|
+
|
|
31
34
|
|
|
32
35
|
def _eprint(*args: Any) -> None:
|
|
33
36
|
print(*args, file=sys.stderr)
|
|
@@ -336,6 +339,72 @@ class _CodexIterState:
|
|
|
336
339
|
lines_malformed: int = 0
|
|
337
340
|
token_events_skipped: int = 0
|
|
338
341
|
skip_reasons: dict = field(default_factory=dict)
|
|
342
|
+
thread: "CodexThreadMetadata | None" = None
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
@dataclass(frozen=True)
|
|
346
|
+
class CodexThreadMetadata:
|
|
347
|
+
"""Source facts established by one ``session_meta`` physical record."""
|
|
348
|
+
source_root_key: str | None
|
|
349
|
+
source_path: str
|
|
350
|
+
native_thread_id: str | None
|
|
351
|
+
root_thread_id: str | None
|
|
352
|
+
parent_thread_id: str | None
|
|
353
|
+
conversation_key: str | None
|
|
354
|
+
cwd: str | None
|
|
355
|
+
git_json: str | None
|
|
356
|
+
source_kind: str | None
|
|
357
|
+
thread_source_json: str | None
|
|
358
|
+
model_provider: str | None
|
|
359
|
+
context_window: int | None
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@dataclass(frozen=True)
|
|
363
|
+
class CodexQuotaObservation:
|
|
364
|
+
"""One validated native quota window from a physical rollout record."""
|
|
365
|
+
source: str
|
|
366
|
+
source_root_key: str | None
|
|
367
|
+
source_path: str
|
|
368
|
+
line_offset: int
|
|
369
|
+
captured_at_utc: str
|
|
370
|
+
observed_slot: str
|
|
371
|
+
logical_limit_key: str
|
|
372
|
+
limit_id: str | None
|
|
373
|
+
limit_name: str | None
|
|
374
|
+
window_minutes: int
|
|
375
|
+
used_percent: float
|
|
376
|
+
resets_at_utc: str
|
|
377
|
+
plan_type: str | None
|
|
378
|
+
individual_limit_json: str | None
|
|
379
|
+
reached_type: str | None
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
@dataclass(frozen=True)
|
|
383
|
+
class CodexPhysicalEvent:
|
|
384
|
+
"""Complete canonical payload retained for one valid JSON object line."""
|
|
385
|
+
source_path: str
|
|
386
|
+
line_offset: int
|
|
387
|
+
source_root_key: str | None
|
|
388
|
+
conversation_key: str | None
|
|
389
|
+
native_thread_id: str | None
|
|
390
|
+
root_thread_id: str | None
|
|
391
|
+
parent_thread_id: str | None
|
|
392
|
+
timestamp_utc: str | None
|
|
393
|
+
record_type: str | None
|
|
394
|
+
event_type: str | None
|
|
395
|
+
turn_id: str | None
|
|
396
|
+
call_id: str | None
|
|
397
|
+
payload_json: str
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
@dataclass(frozen=True)
|
|
401
|
+
class CodexFusedEmission:
|
|
402
|
+
"""All source-derived facts emitted after parsing one physical record once."""
|
|
403
|
+
line_offset: int
|
|
404
|
+
event: CodexPhysicalEvent
|
|
405
|
+
accounting: CodexEntry | None
|
|
406
|
+
quotas: tuple[CodexQuotaObservation, ...]
|
|
407
|
+
thread: CodexThreadMetadata | None
|
|
339
408
|
|
|
340
409
|
|
|
341
410
|
def _codex_skip(state: "_CodexIterState", reason: str) -> None:
|
|
@@ -343,195 +412,493 @@ def _codex_skip(state: "_CodexIterState", reason: str) -> None:
|
|
|
343
412
|
state.skip_reasons[reason] = state.skip_reasons.get(reason, 0) + 1
|
|
344
413
|
|
|
345
414
|
|
|
346
|
-
|
|
415
|
+
_QUOTA_ENVELOPE_FIELDS = frozenset((
|
|
416
|
+
"credits", "plan_type", "limit_id", "limit_name", "individual_limit",
|
|
417
|
+
"rate_limit_reached_type",
|
|
418
|
+
))
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _codex_string(value: object) -> str | None:
|
|
422
|
+
return value if isinstance(value, str) and value.strip() else None
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _codex_canonical_json(value: object) -> str:
|
|
426
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False,
|
|
427
|
+
allow_nan=False)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _parse_codex_timestamp(value: object) -> dt.datetime | None:
|
|
431
|
+
if not isinstance(value, str) or not value.strip():
|
|
432
|
+
return None
|
|
433
|
+
try:
|
|
434
|
+
parsed = dt.datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
|
|
435
|
+
except ValueError:
|
|
436
|
+
return None
|
|
437
|
+
if parsed.tzinfo is None:
|
|
438
|
+
parsed = parsed.replace(tzinfo=dt.timezone.utc)
|
|
439
|
+
return parsed
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _format_codex_timestamp(value: dt.datetime) -> str:
|
|
443
|
+
return value.astimezone(dt.timezone.utc).isoformat().replace("+00:00", "Z")
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _finite_number(value: object) -> float | None:
|
|
447
|
+
# Quota JSON fields are typed numerics. Do not coerce strings: a malformed
|
|
448
|
+
# direct value must yield to a valid typed fallback in payload.info.
|
|
449
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
450
|
+
return None
|
|
451
|
+
try:
|
|
452
|
+
number = float(value)
|
|
453
|
+
except (TypeError, ValueError, OverflowError):
|
|
454
|
+
return None
|
|
455
|
+
return number if math.isfinite(number) else None
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _json_value_is_finite(value: object) -> bool:
|
|
459
|
+
"""Whether a decoded JSON value can be serialized canonically.
|
|
460
|
+
|
|
461
|
+
``json.loads`` rejects the literal NaN/Infinity constants through
|
|
462
|
+
``parse_constant``, but a syntactically valid exponent such as ``1e400``
|
|
463
|
+
is decoded as ``float('inf')``. Reject it before a physical event reaches
|
|
464
|
+
canonical serialization with ``allow_nan=False``.
|
|
465
|
+
"""
|
|
466
|
+
if isinstance(value, float):
|
|
467
|
+
return math.isfinite(value)
|
|
468
|
+
if isinstance(value, dict):
|
|
469
|
+
return all(_json_value_is_finite(item) for item in value.values())
|
|
470
|
+
if isinstance(value, list):
|
|
471
|
+
return all(_json_value_is_finite(item) for item in value)
|
|
472
|
+
return True
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _positive_whole_number(value: object) -> int | None:
|
|
476
|
+
number = _finite_number(value)
|
|
477
|
+
if number is None or number <= 0 or not number.is_integer():
|
|
478
|
+
return None
|
|
479
|
+
return int(number)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _quota_reset_at(value: object) -> str | None:
|
|
483
|
+
numeric = _finite_number(value)
|
|
484
|
+
if numeric is not None:
|
|
485
|
+
try:
|
|
486
|
+
return _format_codex_timestamp(
|
|
487
|
+
dt.datetime.fromtimestamp(numeric, tz=dt.timezone.utc)
|
|
488
|
+
)
|
|
489
|
+
except (OverflowError, OSError, ValueError):
|
|
490
|
+
return None
|
|
491
|
+
parsed = _parse_codex_timestamp(value)
|
|
492
|
+
return _format_codex_timestamp(parsed) if parsed is not None else None
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _first_valid_string(*values: object) -> str | None:
|
|
496
|
+
for value in values:
|
|
497
|
+
string = _codex_string(value)
|
|
498
|
+
if string is not None:
|
|
499
|
+
return string
|
|
500
|
+
return None
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _canonical_container(value: object) -> str | None:
|
|
504
|
+
if not isinstance(value, (dict, list)):
|
|
505
|
+
return None
|
|
506
|
+
return _codex_canonical_json(value)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _thread_metadata_from_session_meta(
|
|
510
|
+
payload: dict[str, Any], path_str: str, source_root_key: str | None,
|
|
511
|
+
) -> CodexThreadMetadata:
|
|
512
|
+
accounting_id = _codex_string(payload.get("id"))
|
|
513
|
+
native_thread_id = _codex_string(payload.get("session_id")) or accounting_id
|
|
514
|
+
root_thread_id = _codex_string(payload.get("thread_source"))
|
|
515
|
+
parent_thread_id = _codex_string(payload.get("forked_from_id"))
|
|
516
|
+
conversation_key = None
|
|
517
|
+
if native_thread_id is not None and root_thread_id is not None:
|
|
518
|
+
conversation_key = canonical_identity_from_root_key(
|
|
519
|
+
"codex", "conversation", source_root_key, native_thread_id, root_thread_id
|
|
520
|
+
)
|
|
521
|
+
return CodexThreadMetadata(
|
|
522
|
+
source_root_key=source_root_key,
|
|
523
|
+
source_path=path_str,
|
|
524
|
+
native_thread_id=native_thread_id,
|
|
525
|
+
root_thread_id=root_thread_id,
|
|
526
|
+
parent_thread_id=parent_thread_id,
|
|
527
|
+
conversation_key=conversation_key,
|
|
528
|
+
cwd=_codex_string(payload.get("cwd")),
|
|
529
|
+
git_json=_canonical_container(payload.get("git")),
|
|
530
|
+
source_kind=_codex_string(payload.get("source")),
|
|
531
|
+
thread_source_json=_canonical_container(payload.get("thread_source")),
|
|
532
|
+
model_provider=_codex_string(payload.get("model_provider")),
|
|
533
|
+
context_window=_positive_whole_number(payload.get("context_window")),
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _first_valid_individual_limit_json(
|
|
538
|
+
direct: dict[str, Any], fallback: dict[str, Any],
|
|
539
|
+
) -> str | None:
|
|
540
|
+
for envelope in (direct, fallback):
|
|
541
|
+
value = envelope.get("individual_limit")
|
|
542
|
+
if isinstance(value, dict):
|
|
543
|
+
return _codex_canonical_json(value)
|
|
544
|
+
if _finite_number(value) is not None:
|
|
545
|
+
return _codex_canonical_json(value)
|
|
546
|
+
return None
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _first_valid_quota_number(
|
|
550
|
+
direct: dict[str, Any], fallback: dict[str, Any], key: str,
|
|
551
|
+
*, minimum: float | None = None, maximum: float | None = None,
|
|
552
|
+
) -> float | None:
|
|
553
|
+
for window in (direct, fallback):
|
|
554
|
+
number = _finite_number(window.get(key))
|
|
555
|
+
if number is None:
|
|
556
|
+
continue
|
|
557
|
+
if minimum is not None and number < minimum:
|
|
558
|
+
continue
|
|
559
|
+
if maximum is not None and number > maximum:
|
|
560
|
+
continue
|
|
561
|
+
return number
|
|
562
|
+
return None
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _first_valid_window_minutes(
|
|
566
|
+
direct: dict[str, Any], fallback: dict[str, Any],
|
|
567
|
+
) -> int | None:
|
|
568
|
+
for window in (direct, fallback):
|
|
569
|
+
minutes = _positive_whole_number(window.get("window_minutes"))
|
|
570
|
+
if minutes is not None:
|
|
571
|
+
return minutes
|
|
572
|
+
return None
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _first_valid_reset_at(
|
|
576
|
+
direct: dict[str, Any], fallback: dict[str, Any],
|
|
577
|
+
) -> str | None:
|
|
578
|
+
for window in (direct, fallback):
|
|
579
|
+
reset_at = _quota_reset_at(window.get("resets_at"))
|
|
580
|
+
if reset_at is not None:
|
|
581
|
+
return reset_at
|
|
582
|
+
return None
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _codex_logical_limit_key(
|
|
586
|
+
source_root_key: str | None, limit_id: str | None, observed_slot: str,
|
|
587
|
+
window_minutes: int,
|
|
588
|
+
) -> str:
|
|
589
|
+
return _codex_canonical_json({
|
|
590
|
+
"limitId": limit_id,
|
|
591
|
+
"observedSlot": observed_slot,
|
|
592
|
+
"source": "codex",
|
|
593
|
+
"sourceRootKey": source_root_key,
|
|
594
|
+
"windowMinutes": window_minutes,
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _codex_quota_observations(
|
|
599
|
+
obj: dict[str, Any], payload: dict[str, Any], path_str: str, line_offset: int,
|
|
600
|
+
source_root_key: str | None,
|
|
601
|
+
) -> tuple[CodexQuotaObservation, ...]:
|
|
602
|
+
captured_at = _parse_codex_timestamp(obj.get("timestamp"))
|
|
603
|
+
if captured_at is None:
|
|
604
|
+
return ()
|
|
605
|
+
direct = payload.get("rate_limits")
|
|
606
|
+
info = payload.get("info")
|
|
607
|
+
fallback = info.get("rate_limits") if isinstance(info, dict) else None
|
|
608
|
+
direct = direct if isinstance(direct, dict) else {}
|
|
609
|
+
fallback = fallback if isinstance(fallback, dict) else {}
|
|
610
|
+
if not direct and not fallback:
|
|
611
|
+
return ()
|
|
612
|
+
|
|
613
|
+
slots = sorted({
|
|
614
|
+
slot for envelope in (direct, fallback) for slot, value in envelope.items()
|
|
615
|
+
if slot not in _QUOTA_ENVELOPE_FIELDS and isinstance(value, dict)
|
|
616
|
+
})
|
|
617
|
+
observations: list[CodexQuotaObservation] = []
|
|
618
|
+
for slot in slots:
|
|
619
|
+
direct_window = direct.get(slot)
|
|
620
|
+
fallback_window = fallback.get(slot)
|
|
621
|
+
direct_window = direct_window if isinstance(direct_window, dict) else {}
|
|
622
|
+
fallback_window = fallback_window if isinstance(fallback_window, dict) else {}
|
|
623
|
+
used_percent = _first_valid_quota_number(
|
|
624
|
+
direct_window, fallback_window, "used_percent", minimum=0.0, maximum=100.0
|
|
625
|
+
)
|
|
626
|
+
window_minutes = _first_valid_window_minutes(direct_window, fallback_window)
|
|
627
|
+
resets_at_utc = _first_valid_reset_at(direct_window, fallback_window)
|
|
628
|
+
if used_percent is None or window_minutes is None or resets_at_utc is None:
|
|
629
|
+
continue
|
|
630
|
+
limit_id = _first_valid_string(
|
|
631
|
+
direct.get("limit_id"), fallback.get("limit_id")
|
|
632
|
+
)
|
|
633
|
+
observations.append(CodexQuotaObservation(
|
|
634
|
+
source="codex",
|
|
635
|
+
source_root_key=source_root_key,
|
|
636
|
+
source_path=path_str,
|
|
637
|
+
line_offset=line_offset,
|
|
638
|
+
captured_at_utc=_format_codex_timestamp(captured_at),
|
|
639
|
+
observed_slot=slot,
|
|
640
|
+
logical_limit_key=_codex_logical_limit_key(
|
|
641
|
+
source_root_key, limit_id, slot, window_minutes
|
|
642
|
+
),
|
|
643
|
+
limit_id=limit_id,
|
|
644
|
+
limit_name=_first_valid_string(
|
|
645
|
+
direct.get("limit_name"), fallback.get("limit_name")
|
|
646
|
+
),
|
|
647
|
+
window_minutes=window_minutes,
|
|
648
|
+
used_percent=used_percent,
|
|
649
|
+
resets_at_utc=resets_at_utc,
|
|
650
|
+
plan_type=_first_valid_string(
|
|
651
|
+
direct.get("plan_type"), fallback.get("plan_type")
|
|
652
|
+
),
|
|
653
|
+
individual_limit_json=_first_valid_individual_limit_json(direct, fallback),
|
|
654
|
+
reached_type=_first_valid_string(
|
|
655
|
+
direct.get("rate_limit_reached_type"),
|
|
656
|
+
fallback.get("rate_limit_reached_type"),
|
|
657
|
+
),
|
|
658
|
+
))
|
|
659
|
+
return tuple(observations)
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _reject_nonfinite_json_constant(value: str) -> None:
|
|
663
|
+
raise ValueError(f"non-finite JSON constant: {value}")
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _seed_codex_iter_state(
|
|
667
|
+
state: _CodexIterState, initial_session_id: str | None,
|
|
668
|
+
initial_model: str | None, initial_total_tokens: int,
|
|
669
|
+
) -> None:
|
|
670
|
+
if state.session_id is None and initial_session_id is not None:
|
|
671
|
+
state.session_id = initial_session_id
|
|
672
|
+
if state.model is None and initial_model is not None:
|
|
673
|
+
state.model = initial_model
|
|
674
|
+
if state.total_tokens == 0 and initial_total_tokens:
|
|
675
|
+
state.total_tokens = int(initial_total_tokens)
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _event_from_record(
|
|
679
|
+
obj: dict[str, Any], path_str: str, line_offset: int,
|
|
680
|
+
source_root_key: str | None, thread: CodexThreadMetadata | None,
|
|
681
|
+
) -> CodexPhysicalEvent:
|
|
682
|
+
payload = obj.get("payload") if isinstance(obj.get("payload"), dict) else {}
|
|
683
|
+
record_type = _codex_string(obj.get("type")) or _codex_string(obj.get("record_type"))
|
|
684
|
+
return CodexPhysicalEvent(
|
|
685
|
+
source_path=path_str,
|
|
686
|
+
line_offset=line_offset,
|
|
687
|
+
source_root_key=source_root_key,
|
|
688
|
+
conversation_key=thread.conversation_key if thread is not None else None,
|
|
689
|
+
native_thread_id=thread.native_thread_id if thread is not None else None,
|
|
690
|
+
root_thread_id=thread.root_thread_id if thread is not None else None,
|
|
691
|
+
parent_thread_id=thread.parent_thread_id if thread is not None else None,
|
|
692
|
+
timestamp_utc=(
|
|
693
|
+
_format_codex_timestamp(timestamp)
|
|
694
|
+
if (timestamp := _parse_codex_timestamp(obj.get("timestamp"))) is not None
|
|
695
|
+
else None
|
|
696
|
+
),
|
|
697
|
+
record_type=record_type,
|
|
698
|
+
event_type=_codex_string(payload.get("type")),
|
|
699
|
+
turn_id=_codex_string(payload.get("turn_id")),
|
|
700
|
+
call_id=_codex_string(payload.get("call_id")),
|
|
701
|
+
payload_json=_codex_canonical_json(obj),
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _accounting_from_record(
|
|
706
|
+
obj: dict[str, Any], payload: dict[str, Any], path_str: str,
|
|
707
|
+
state: _CodexIterState, last_total_tokens: int,
|
|
708
|
+
filename_uuid: str | None, filename_session_id_warned: bool,
|
|
709
|
+
) -> tuple[CodexEntry | None, int, bool]:
|
|
710
|
+
if obj.get("type") != "event_msg" or payload.get("type") != "token_count":
|
|
711
|
+
return None, last_total_tokens, filename_session_id_warned
|
|
712
|
+
info = payload.get("info")
|
|
713
|
+
if info is None:
|
|
714
|
+
return None, last_total_tokens, filename_session_id_warned
|
|
715
|
+
if not isinstance(info, dict):
|
|
716
|
+
_codex_skip(state, "info-non-dict")
|
|
717
|
+
return None, last_total_tokens, filename_session_id_warned
|
|
718
|
+
last_token_usage = info.get("last_token_usage")
|
|
719
|
+
if not isinstance(last_token_usage, dict):
|
|
720
|
+
_codex_skip(state, "no-last-token-usage")
|
|
721
|
+
return None, last_total_tokens, filename_session_id_warned
|
|
722
|
+
|
|
723
|
+
total_token_usage = info.get("total_token_usage")
|
|
724
|
+
if isinstance(total_token_usage, dict):
|
|
725
|
+
try:
|
|
726
|
+
cumulative = int(total_token_usage.get("total_tokens") or 0)
|
|
727
|
+
except (TypeError, ValueError):
|
|
728
|
+
cumulative = 0
|
|
729
|
+
if cumulative <= last_total_tokens:
|
|
730
|
+
return None, last_total_tokens, filename_session_id_warned
|
|
731
|
+
else:
|
|
732
|
+
cumulative = None
|
|
733
|
+
|
|
734
|
+
timestamp = _parse_codex_timestamp(obj.get("timestamp"))
|
|
735
|
+
if timestamp is None:
|
|
736
|
+
_codex_skip(state, "bad-timestamp")
|
|
737
|
+
return None, last_total_tokens, filename_session_id_warned
|
|
738
|
+
session_id = state.session_id
|
|
739
|
+
if session_id is None:
|
|
740
|
+
session_id = filename_uuid
|
|
741
|
+
if session_id is not None and not filename_session_id_warned:
|
|
742
|
+
_eprint(
|
|
743
|
+
f"[codex] session_meta not seen in {path_str}; "
|
|
744
|
+
f"falling back to filename UUID {session_id}"
|
|
745
|
+
)
|
|
746
|
+
filename_session_id_warned = True
|
|
747
|
+
if session_id is None:
|
|
748
|
+
_codex_skip(state, "no-session-id")
|
|
749
|
+
return None, last_total_tokens, filename_session_id_warned
|
|
750
|
+
|
|
751
|
+
def token_count(key: str) -> int:
|
|
752
|
+
try:
|
|
753
|
+
return int(last_token_usage.get(key) or 0)
|
|
754
|
+
except (TypeError, ValueError):
|
|
755
|
+
return 0
|
|
756
|
+
|
|
757
|
+
entry = CodexEntry(
|
|
758
|
+
timestamp=timestamp,
|
|
759
|
+
session_id=session_id,
|
|
760
|
+
model=state.model or "unknown",
|
|
761
|
+
input_tokens=token_count("input_tokens"),
|
|
762
|
+
cached_input_tokens=token_count("cached_input_tokens"),
|
|
763
|
+
output_tokens=token_count("output_tokens"),
|
|
764
|
+
reasoning_output_tokens=token_count("reasoning_output_tokens"),
|
|
765
|
+
total_tokens=token_count("total_tokens"),
|
|
766
|
+
source_path=path_str,
|
|
767
|
+
)
|
|
768
|
+
if cumulative is not None:
|
|
769
|
+
state.total_tokens = cumulative
|
|
770
|
+
last_total_tokens = cumulative
|
|
771
|
+
return entry, last_total_tokens, filename_session_id_warned
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def _iter_codex_fused_records_with_offsets(
|
|
347
775
|
fh,
|
|
348
776
|
path_str: str,
|
|
349
777
|
*,
|
|
350
778
|
initial_session_id: str | None = None,
|
|
351
779
|
initial_model: str | None = None,
|
|
352
780
|
initial_total_tokens: int = 0,
|
|
781
|
+
source_root_key: str | None = None,
|
|
353
782
|
state: _CodexIterState | None = None,
|
|
354
783
|
):
|
|
355
|
-
"""Yield
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
the
|
|
360
|
-
initial_model so attribution stays correct even if the new byte range
|
|
361
|
-
contains no fresh session_meta / turn_context record.
|
|
362
|
-
|
|
363
|
-
If `state` is supplied it is updated in-place on every `session_meta`
|
|
364
|
-
/ `turn_context` record regardless of whether any subsequent
|
|
365
|
-
`token_count` actually yields. This lets callers observe the iterator's
|
|
366
|
-
terminal state even when the delta window ends on a metadata record —
|
|
367
|
-
otherwise `last_model` would silently persist a stale value and the
|
|
368
|
-
next resume would mis-attribute the first post-resume token_count.
|
|
369
|
-
|
|
370
|
-
Skips token_count events with payload.info == None (rate-limit-only
|
|
371
|
-
events). Falls back to filename-derived session_id with a one-shot warning
|
|
372
|
-
if session_meta is never observed.
|
|
373
|
-
|
|
374
|
-
Codex CLI emits multiple `token_count` events per completed turn (UI/
|
|
375
|
-
turn_context updates re-emit the same `last_token_usage` while the
|
|
376
|
-
cumulative `info.total_token_usage.total_tokens` stays flat). To avoid
|
|
377
|
-
double-counting, we track the cumulative total across yields and skip
|
|
378
|
-
any event whose cumulative total is not strictly greater than the
|
|
379
|
-
previously-seen cumulative. Callers doing delta resumes should pass the
|
|
380
|
-
last persisted cumulative as `initial_total_tokens`. If `total_token_usage`
|
|
381
|
-
is missing or non-dict (older Codex builds), we fall back to yielding
|
|
382
|
-
unconditionally — preserving legacy behavior on those rollouts.
|
|
383
|
-
|
|
384
|
-
Readline()+tell() is used rather than `for line in fh` so byte offsets
|
|
385
|
-
are accurate for resume-from-offset after partial ingests. Partial-tail
|
|
386
|
-
lines (no trailing \\n) trigger a seek-back so the next sync re-reads
|
|
387
|
-
the line once the newline is flushed.
|
|
784
|
+
"""Yield typed facts for every complete, valid Codex JSONL object.
|
|
785
|
+
|
|
786
|
+
Binary readers retain physical byte offsets and strictly reject invalid UTF-8,
|
|
787
|
+
non-finite JSON constants, and non-object JSON. The mutable state carries
|
|
788
|
+
the shipped accounting resume contract alongside the latest thread facts.
|
|
388
789
|
"""
|
|
389
790
|
if state is None:
|
|
390
791
|
state = _CodexIterState()
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
# contract for callers that pass kwargs without a state object, while
|
|
394
|
-
# letting callers who DO pass a pre-populated state see it honored.
|
|
395
|
-
if state.session_id is None and initial_session_id is not None:
|
|
396
|
-
state.session_id = initial_session_id
|
|
397
|
-
if state.model is None and initial_model is not None:
|
|
398
|
-
state.model = initial_model
|
|
399
|
-
# #279 S3 F1: the state carries the REAL dedup watermark. Seeding
|
|
400
|
-
# precedence mirrors session_id/model: a caller-supplied NON-ZERO state
|
|
401
|
-
# wins; the kwarg seeds otherwise. (total_tokens has no None sentinel —
|
|
402
|
-
# 0 IS the unset value; a genuine 0 watermark and "unset" are the same
|
|
403
|
-
# thing: both mean "dedupe against nothing".)
|
|
404
|
-
if state.total_tokens == 0 and initial_total_tokens:
|
|
405
|
-
state.total_tokens = int(initial_total_tokens)
|
|
406
|
-
last_total_tokens: int = state.total_tokens
|
|
407
|
-
# Suppress the filename-UUID fallback warning when we already have a
|
|
408
|
-
# seeded session_id (delta resume path). Without this, every resume
|
|
409
|
-
# into a slice of the file that doesn't re-observe session_meta would
|
|
410
|
-
# noisily warn even though attribution is correct.
|
|
792
|
+
_seed_codex_iter_state(state, initial_session_id, initial_model, initial_total_tokens)
|
|
793
|
+
last_total_tokens = state.total_tokens
|
|
411
794
|
filename_session_id_warned = state.session_id is not None
|
|
412
795
|
filename_uuid_match = _CODEX_FILENAME_UUID_RE.search(path_str)
|
|
413
796
|
filename_uuid = filename_uuid_match.group(1) if filename_uuid_match else None
|
|
414
797
|
|
|
415
798
|
while True:
|
|
416
|
-
|
|
799
|
+
line_offset = fh.tell()
|
|
417
800
|
line = fh.readline()
|
|
418
801
|
if not line:
|
|
419
802
|
return
|
|
420
|
-
if
|
|
421
|
-
|
|
803
|
+
if isinstance(line, bytes):
|
|
804
|
+
complete = line.endswith(b"\n")
|
|
805
|
+
text: str | None
|
|
806
|
+
try:
|
|
807
|
+
text = line.decode("utf-8")
|
|
808
|
+
except UnicodeDecodeError:
|
|
809
|
+
text = None
|
|
810
|
+
else:
|
|
811
|
+
complete = line.endswith("\n")
|
|
812
|
+
text = line
|
|
813
|
+
if not complete:
|
|
814
|
+
fh.seek(line_offset)
|
|
422
815
|
return
|
|
423
|
-
|
|
816
|
+
if text is None:
|
|
817
|
+
state.lines_seen += 1
|
|
818
|
+
state.lines_malformed += 1
|
|
819
|
+
continue
|
|
820
|
+
stripped = text.strip()
|
|
424
821
|
if not stripped:
|
|
425
822
|
continue
|
|
426
|
-
state.lines_seen += 1
|
|
823
|
+
state.lines_seen += 1
|
|
427
824
|
try:
|
|
428
|
-
obj = json.loads(stripped)
|
|
429
|
-
except json.JSONDecodeError:
|
|
825
|
+
obj = json.loads(stripped, parse_constant=_reject_nonfinite_json_constant)
|
|
826
|
+
except (json.JSONDecodeError, ValueError, TypeError):
|
|
430
827
|
state.lines_malformed += 1
|
|
431
828
|
continue
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
payload = obj.get("payload") if isinstance(obj.get("payload"), dict) else {}
|
|
435
|
-
|
|
436
|
-
if rtype == "session_meta":
|
|
437
|
-
sid = payload.get("id")
|
|
438
|
-
if isinstance(sid, str) and sid:
|
|
439
|
-
state.session_id = sid
|
|
440
|
-
continue
|
|
441
|
-
|
|
442
|
-
if rtype == "turn_context":
|
|
443
|
-
m = payload.get("model")
|
|
444
|
-
if isinstance(m, str) and m.strip():
|
|
445
|
-
state.model = m.strip()
|
|
446
|
-
continue
|
|
447
|
-
|
|
448
|
-
if rtype != "event_msg":
|
|
449
|
-
continue
|
|
450
|
-
|
|
451
|
-
if payload.get("type") != "token_count":
|
|
452
|
-
continue
|
|
453
|
-
info = payload.get("info")
|
|
454
|
-
if info is None:
|
|
455
|
-
continue # rate-limit-only event — normal, not drift
|
|
456
|
-
if not isinstance(info, dict):
|
|
457
|
-
_codex_skip(state, "info-non-dict")
|
|
829
|
+
if not isinstance(obj, dict):
|
|
830
|
+
state.lines_malformed += 1
|
|
458
831
|
continue
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
_codex_skip(state, "no-last-token-usage")
|
|
832
|
+
if not _json_value_is_finite(obj):
|
|
833
|
+
state.lines_malformed += 1
|
|
462
834
|
continue
|
|
463
835
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
836
|
+
payload = obj.get("payload") if isinstance(obj.get("payload"), dict) else {}
|
|
837
|
+
thread_update = None
|
|
838
|
+
if obj.get("type") == "session_meta":
|
|
839
|
+
session_id = _codex_string(payload.get("id"))
|
|
840
|
+
if session_id is not None:
|
|
841
|
+
state.session_id = session_id
|
|
842
|
+
thread_update = _thread_metadata_from_session_meta(
|
|
843
|
+
payload, path_str, source_root_key
|
|
844
|
+
)
|
|
845
|
+
state.thread = thread_update
|
|
846
|
+
elif obj.get("type") == "turn_context":
|
|
847
|
+
model = _codex_string(payload.get("model"))
|
|
848
|
+
if model is not None:
|
|
849
|
+
state.model = model.strip()
|
|
850
|
+
|
|
851
|
+
event = _event_from_record(
|
|
852
|
+
obj, path_str, line_offset, source_root_key, state.thread
|
|
853
|
+
)
|
|
854
|
+
accounting, last_total_tokens, filename_session_id_warned = _accounting_from_record(
|
|
855
|
+
obj, payload, path_str, state, last_total_tokens, filename_uuid,
|
|
856
|
+
filename_session_id_warned,
|
|
857
|
+
)
|
|
858
|
+
quotas = _codex_quota_observations(
|
|
859
|
+
obj, payload, path_str, line_offset, source_root_key
|
|
860
|
+
)
|
|
861
|
+
yield CodexFusedEmission(
|
|
862
|
+
line_offset=line_offset,
|
|
863
|
+
event=event,
|
|
864
|
+
accounting=accounting,
|
|
865
|
+
quotas=quotas,
|
|
866
|
+
thread=thread_update,
|
|
867
|
+
)
|
|
479
868
|
|
|
480
|
-
ts_raw = obj.get("timestamp")
|
|
481
|
-
if not isinstance(ts_raw, str) or not ts_raw.strip():
|
|
482
|
-
_codex_skip(state, "bad-timestamp")
|
|
483
|
-
continue
|
|
484
|
-
try:
|
|
485
|
-
ts = dt.datetime.fromisoformat(ts_raw.strip().replace("Z", "+00:00"))
|
|
486
|
-
if ts.tzinfo is None:
|
|
487
|
-
ts = ts.replace(tzinfo=dt.timezone.utc)
|
|
488
|
-
except ValueError:
|
|
489
|
-
_codex_skip(state, "bad-timestamp")
|
|
490
|
-
continue
|
|
491
869
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
),
|
|
528
|
-
)
|
|
529
|
-
# Advance the cumulative watermark only after a successful yield so
|
|
530
|
-
# resume-from-offset continues to dedupe against the last counted turn.
|
|
531
|
-
# #279 S3 F1: stamp the state too — after the iterator drains,
|
|
532
|
-
# state.total_tokens IS the guard's terminal watermark, which the
|
|
533
|
-
# caller persists (the old reconstructed initial+Σ(per-turn) sum could
|
|
534
|
-
# diverge from it and double-count/skip on resume).
|
|
535
|
-
if isinstance(ttu, dict) and cumulative is not None:
|
|
536
|
-
last_total_tokens = cumulative
|
|
537
|
-
state.total_tokens = cumulative
|
|
870
|
+
def _iter_codex_jsonl_entries_with_offsets(
|
|
871
|
+
fh,
|
|
872
|
+
path_str: str,
|
|
873
|
+
*,
|
|
874
|
+
initial_session_id: str | None = None,
|
|
875
|
+
initial_model: str | None = None,
|
|
876
|
+
initial_total_tokens: int = 0,
|
|
877
|
+
state: _CodexIterState | None = None,
|
|
878
|
+
):
|
|
879
|
+
"""Compatibility wrapper exposing only existing accounting emissions."""
|
|
880
|
+
binary_fh = fh
|
|
881
|
+
text_fh = None
|
|
882
|
+
try:
|
|
883
|
+
if isinstance(fh.read(0), str) and hasattr(fh, "buffer"):
|
|
884
|
+
text_fh = fh
|
|
885
|
+
position = fh.tell()
|
|
886
|
+
fh.seek(position)
|
|
887
|
+
binary_fh = fh.buffer
|
|
888
|
+
binary_fh.seek(position)
|
|
889
|
+
except (AttributeError, OSError, TypeError):
|
|
890
|
+
pass
|
|
891
|
+
try:
|
|
892
|
+
for emission in _iter_codex_fused_records_with_offsets(
|
|
893
|
+
binary_fh,
|
|
894
|
+
path_str,
|
|
895
|
+
initial_session_id=initial_session_id,
|
|
896
|
+
initial_model=initial_model,
|
|
897
|
+
initial_total_tokens=initial_total_tokens,
|
|
898
|
+
state=state,
|
|
899
|
+
):
|
|
900
|
+
if emission.accounting is not None:
|
|
901
|
+
yield emission.line_offset, emission.accounting
|
|
902
|
+
finally:
|
|
903
|
+
if text_fh is not None:
|
|
904
|
+
text_fh.seek(binary_fh.tell())
|