cdx-manager 0.19.1 → 0.19.3
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/README.md +3 -3
- package/VERSION +1 -1
- package/changelogs/CHANGELOGS_0_19_2.md +11 -0
- package/changelogs/CHANGELOGS_0_19_3.md +6 -0
- package/checksums/release-archives.json +15 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/commands/launch.py +16 -1
- package/src/commands/status.py +5 -2
- package/src/fs_utils.py +9 -0
- package/src/interactive_usage.py +124 -0
- package/src/run_registry.py +22 -14
- package/src/run_usage.py +8 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<br clear="left"/>
|
|
6
6
|
|
|
7
|
-
[](LICENSE) ](LICENSE)  
|
|
8
8
|
|
|
9
9
|
**Stop guessing which AI account still has quota.** `cdx` tracks the rate-limit window of every Codex and Claude account you own, tells you which one is usable right now, and launches it with isolated auth — in one command.
|
|
10
10
|
|
|
@@ -112,7 +112,7 @@ The reason `cdx` exists: knowing, across every account you own, which one you ca
|
|
|
112
112
|
### Maintenance
|
|
113
113
|
|
|
114
114
|
- **Launch history.** Inspect recent launches with provider, result, duration, working directory, launch settings, and transcript path.
|
|
115
|
-
- **Aggregate usage.** `cdx stats` totals runs, token usage, and time spent per session over any period.
|
|
115
|
+
- **Aggregate usage.** `cdx stats` totals runs, token usage, and time spent per session over any period. Interactive Codex and Claude launches also record provider-native token totals when their local session transcript is available.
|
|
116
116
|
|
|
117
117
|
- **Disk usage and cleanup.** `cdx disk` reports `CDX_HOME` usage, `cdx disk profiles --candidates` identifies reclaimable profile caches/logs with evidence, and `cdx clean profiles ...` applies explicit cleanup actions.
|
|
118
118
|
- **Health and repair.** `cdx doctor` inspects dependencies, permissions, and orphan profiles; `cdx repair` plans or applies safe fixes.
|
|
@@ -708,7 +708,7 @@ cdx run \
|
|
|
708
708
|
|
|
709
709
|
The result includes `launcher: "cdx"`, `run_id`, selected `session`, `provider`, `exit_code`, `duration_seconds`, absolute `transcript_path`, `stdout_path`, `stderr_path`, and normalized usage token fields. Codex headless runs use `codex exec --json`; Claude headless runs use `claude --print --output-format json`. Token counts are `null` when the provider does not expose a supported JSON or JSONL usage shape.
|
|
710
710
|
|
|
711
|
-
Known headless token usage is persisted in launch history and can be aggregated later:
|
|
711
|
+
Known headless and completed interactive token usage is persisted in launch history and can be aggregated later. Interactive records are best-effort local data: Codex reads its latest cumulative rollout snapshot and Claude reads its assistant usage records. Cached input is retained separately from the provider's total, so it is never counted twice.
|
|
712
712
|
|
|
713
713
|
```bash
|
|
714
714
|
cdx stats --since 7d --json
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.19.
|
|
1
|
+
0.19.3
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# CDX Manager 0.19.2
|
|
2
|
+
|
|
3
|
+
## Features
|
|
4
|
+
|
|
5
|
+
- Record Codex and Claude interactive CLI token usage, including cached input tokens, in existing run history and status statistics.
|
|
6
|
+
|
|
7
|
+
## Fixes
|
|
8
|
+
|
|
9
|
+
- Make detached-run registry persistence durable and lock acquisition bounded on POSIX and Windows.
|
|
10
|
+
- Bound interactive transcript discovery and parsing while preserving fail-open behavior.
|
|
11
|
+
- Verify tray tests, macOS CI coverage, and installable npm/wheel package artifacts before release.
|
|
@@ -283,6 +283,21 @@
|
|
|
283
283
|
"v0.19.0": {
|
|
284
284
|
"github_tarball_sha256": "d9816c0e4a2313bc10d8d9b527795b3027798607af49c5ef820f491d167176b4",
|
|
285
285
|
"github_zip_sha256": "656090f68694695fd947b0e73fc8c1ef66785054fd04054f8659b03997daedfb"
|
|
286
|
+
},
|
|
287
|
+
"v0.19.1": {
|
|
288
|
+
"github_tarball_sha256": "351b8840a1dc073a91d198c2d565d9666f9ea7a8d545bac6a01e31ae41aec3ca",
|
|
289
|
+
"github_zip_sha256": "7e74a60fdf20b66faec25d11a8b8780fe4c1fad4a62b24f2395ea7d3667549ae",
|
|
290
|
+
"tray_assets": {
|
|
291
|
+
"aarch64-apple-darwin": "fd07386f7d7d8528927980929a260f3816b050a8d985186a18dae0ce8563af56",
|
|
292
|
+
"aarch64-unknown-linux-musl": "ca963298f96bb125892472cbcb125513f11941cb29258ad2b1ea3b9fb441ff74",
|
|
293
|
+
"x86_64-apple-darwin": "b22601c1c203a17f143ce83e19c5a4c54c546ea52f927f2ad8042ce5aa57a16c",
|
|
294
|
+
"x86_64-pc-windows-msvc": "7d5b01ab05a83c5e5a18f515a7010b799c19b3b6b6374ed39d8fa386ea66892b",
|
|
295
|
+
"x86_64-unknown-linux-musl": "2b318774e1ddccda5e070475042c395c6c90dda3ec89a6cf6498ba11dbdc04e8"
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
"v0.19.2": {
|
|
299
|
+
"github_tarball_sha256": "d644666c727b2a29fa022700da1c23483173a2110606b994d200f72feb959c3b",
|
|
300
|
+
"github_zip_sha256": "bca42637ddab4502e2dd6d9d63239eca322d953f8d910d8ada209fcfdd2f98d4"
|
|
286
301
|
}
|
|
287
302
|
}
|
|
288
303
|
}
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
package/src/commands/launch.py
CHANGED
|
@@ -25,6 +25,7 @@ from ..cli_render import _dim, _info, _success, _warn
|
|
|
25
25
|
from ..config import PROVIDER_CODEX
|
|
26
26
|
from ..context_store import install_context_for_session, write_context
|
|
27
27
|
from ..errors import CdxError
|
|
28
|
+
from ..interactive_usage import extract_interactive_usage
|
|
28
29
|
from ..provider_runtime import _ensure_session_authentication, _get_auth_home, _run_interactive_provider_command
|
|
29
30
|
|
|
30
31
|
|
|
@@ -264,6 +265,7 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
|
|
|
264
265
|
)
|
|
265
266
|
except CdxError as error:
|
|
266
267
|
run_info = getattr(error, "run_info", {}) or {}
|
|
268
|
+
_attach_interactive_usage(session, run_info)
|
|
267
269
|
if runtime_run_id:
|
|
268
270
|
ctx["service"]["finish_session_runtime"](
|
|
269
271
|
session["name"],
|
|
@@ -284,7 +286,7 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
|
|
|
284
286
|
"action": "resume" if resume else "launch",
|
|
285
287
|
"cwd": cwd,
|
|
286
288
|
"exit_code": 0,
|
|
287
|
-
**run_info,
|
|
289
|
+
**_attach_interactive_usage(session, run_info),
|
|
288
290
|
})
|
|
289
291
|
if json_flag:
|
|
290
292
|
extra = {"session": ctx["service"]["get_session"](session["name"]), "cwd": cwd}
|
|
@@ -293,6 +295,19 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
|
|
|
293
295
|
_write_json(ctx, _json_success("resume" if resume else "launch", message, warnings=warnings, **extra))
|
|
294
296
|
return 0
|
|
295
297
|
|
|
298
|
+
|
|
299
|
+
def _attach_interactive_usage(session, run_info):
|
|
300
|
+
"""Attach best-effort local provider usage without affecting launch outcome."""
|
|
301
|
+
run_info = dict(run_info or {})
|
|
302
|
+
usage, provider_transcript = extract_interactive_usage(
|
|
303
|
+
session.get("provider"), _get_auth_home(session), run_info.get("started_at")
|
|
304
|
+
)
|
|
305
|
+
if usage:
|
|
306
|
+
run_info["usage"] = usage
|
|
307
|
+
if provider_transcript:
|
|
308
|
+
run_info["provider_transcript_path"] = provider_transcript
|
|
309
|
+
return run_info
|
|
310
|
+
|
|
296
311
|
def handle_handoff(rest, ctx):
|
|
297
312
|
json_flag, args = _parse_json_flag(rest)
|
|
298
313
|
if len(args) not in (1, 2):
|
package/src/commands/status.py
CHANGED
|
@@ -253,6 +253,7 @@ def _summarize_stats(entries):
|
|
|
253
253
|
"duration_ms": 0,
|
|
254
254
|
"usage_runs": 0,
|
|
255
255
|
"input_tokens": 0,
|
|
256
|
+
"cached_input_tokens": 0,
|
|
256
257
|
"output_tokens": 0,
|
|
257
258
|
"reasoning_tokens": 0,
|
|
258
259
|
"total_tokens": 0,
|
|
@@ -270,7 +271,7 @@ def _summarize_stats(entries):
|
|
|
270
271
|
usage = entry.get("usage") if isinstance(entry.get("usage"), dict) else {}
|
|
271
272
|
parsed_usage = {
|
|
272
273
|
key: _token_value(usage, key)
|
|
273
|
-
for key in ("input_tokens", "output_tokens", "reasoning_tokens", "total_tokens")
|
|
274
|
+
for key in ("input_tokens", "cached_input_tokens", "output_tokens", "reasoning_tokens", "total_tokens")
|
|
274
275
|
}
|
|
275
276
|
if any(value is not None for value in parsed_usage.values()):
|
|
276
277
|
row["usage_runs"] += 1
|
|
@@ -295,6 +296,7 @@ def _stats_totals(rows):
|
|
|
295
296
|
"duration_ms": sum(row["duration_ms"] for row in rows),
|
|
296
297
|
"usage_runs": sum(row["usage_runs"] for row in rows),
|
|
297
298
|
"input_tokens": sum(row["input_tokens"] for row in rows),
|
|
299
|
+
"cached_input_tokens": sum(row["cached_input_tokens"] for row in rows),
|
|
298
300
|
"output_tokens": sum(row["output_tokens"] for row in rows),
|
|
299
301
|
"reasoning_tokens": sum(row["reasoning_tokens"] for row in rows),
|
|
300
302
|
"total_tokens": sum(row["total_tokens"] for row in rows),
|
|
@@ -389,7 +391,7 @@ def _format_stats(rows, totals, period=None, use_color=False, active_sessions=No
|
|
|
389
391
|
if not rows:
|
|
390
392
|
return "No launch stats for this period." if _has_history_period(period or {}) else "No launch stats."
|
|
391
393
|
table = [[_style(value, "1", use_color) for value in [
|
|
392
|
-
"SESSION", "PROV.", "RUNS", "USAGE", "IN", "OUT", "REASON", "TOTAL", "TIME", "LAST"
|
|
394
|
+
"SESSION", "PROV.", "RUNS", "USAGE", "IN", "CACHE", "OUT", "REASON", "TOTAL", "TIME", "LAST"
|
|
393
395
|
]]]
|
|
394
396
|
for row in rows:
|
|
395
397
|
session_name = row["session_name"]
|
|
@@ -400,6 +402,7 @@ def _format_stats(rows, totals, period=None, use_color=False, active_sessions=No
|
|
|
400
402
|
_style(str(row["launches"]), "1", use_color),
|
|
401
403
|
_style(str(row["usage_runs"]), "32" if row["usage_runs"] else "2", use_color),
|
|
402
404
|
_style(_format_token_count(row["input_tokens"]), "96" if row["input_tokens"] else "2", use_color),
|
|
405
|
+
_style(_format_token_count(row["cached_input_tokens"]), "96" if row["cached_input_tokens"] else "2", use_color),
|
|
403
406
|
_style(_format_token_count(row["output_tokens"]), "96" if row["output_tokens"] else "2", use_color),
|
|
404
407
|
_style(_format_token_count(row["reasoning_tokens"]), "95" if row["reasoning_tokens"] else "2", use_color),
|
|
405
408
|
_style(_format_token_count(row["total_tokens"]), "1;96" if row["total_tokens"] else "2", use_color),
|
package/src/fs_utils.py
CHANGED
|
@@ -28,6 +28,15 @@ def atomic_write(path, data, mode=None):
|
|
|
28
28
|
if mode is not None and os.name != "nt":
|
|
29
29
|
os.chmod(temp_path, mode)
|
|
30
30
|
os.replace(temp_path, path)
|
|
31
|
+
if os.name != "nt":
|
|
32
|
+
try:
|
|
33
|
+
directory_fd = os.open(directory, os.O_RDONLY)
|
|
34
|
+
try:
|
|
35
|
+
os.fsync(directory_fd)
|
|
36
|
+
finally:
|
|
37
|
+
os.close(directory_fd)
|
|
38
|
+
except OSError:
|
|
39
|
+
pass
|
|
31
40
|
except Exception:
|
|
32
41
|
try:
|
|
33
42
|
os.unlink(temp_path)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Best-effort usage readers for interactive provider sessions."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
MAX_TRANSCRIPT_BYTES = 16 * 1024 * 1024
|
|
8
|
+
MAX_TRANSCRIPT_CANDIDATES = 1000
|
|
9
|
+
MAX_TRANSCRIPT_SCAN_SECONDS = 1
|
|
10
|
+
|
|
11
|
+
def extract_interactive_usage(provider, auth_home, started_at=None):
|
|
12
|
+
"""Return the newest provider-native usage after an interactive session.
|
|
13
|
+
|
|
14
|
+
Provider transcripts are not stable public APIs. A missing or changed
|
|
15
|
+
record is therefore ordinary absence, never a launch failure.
|
|
16
|
+
"""
|
|
17
|
+
path = _latest_transcript(provider, auth_home, started_at)
|
|
18
|
+
if not path:
|
|
19
|
+
return None, None
|
|
20
|
+
try:
|
|
21
|
+
if os.path.getsize(path) > MAX_TRANSCRIPT_BYTES:
|
|
22
|
+
return None, path
|
|
23
|
+
with open(path, encoding="utf-8", errors="replace") as handle:
|
|
24
|
+
return (_codex_usage(handle) if provider == "codex" else _claude_usage(handle)), path
|
|
25
|
+
except OSError:
|
|
26
|
+
return None, path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _latest_transcript(provider, auth_home, started_at):
|
|
30
|
+
if not auth_home:
|
|
31
|
+
return None
|
|
32
|
+
root = os.path.join(auth_home, "sessions") if provider == "codex" else os.path.join(auth_home, ".claude", "projects")
|
|
33
|
+
if not os.path.isdir(root):
|
|
34
|
+
return None
|
|
35
|
+
cutoff = _timestamp(started_at)
|
|
36
|
+
candidates = []
|
|
37
|
+
deadline = time.monotonic() + MAX_TRANSCRIPT_SCAN_SECONDS
|
|
38
|
+
for directory, subdirs, names in os.walk(root):
|
|
39
|
+
if time.monotonic() >= deadline:
|
|
40
|
+
return max(candidates, default=(None, None))[1]
|
|
41
|
+
if provider == "claude":
|
|
42
|
+
subdirs[:] = [name for name in subdirs if name != "subagents"]
|
|
43
|
+
for name in names:
|
|
44
|
+
if not name.endswith(".jsonl"):
|
|
45
|
+
continue
|
|
46
|
+
path = os.path.join(directory, name)
|
|
47
|
+
try:
|
|
48
|
+
modified = os.path.getmtime(path)
|
|
49
|
+
except OSError:
|
|
50
|
+
continue
|
|
51
|
+
if cutoff is None or modified >= cutoff:
|
|
52
|
+
candidates.append((modified, path))
|
|
53
|
+
if len(candidates) > MAX_TRANSCRIPT_CANDIDATES:
|
|
54
|
+
return max(candidates)[1]
|
|
55
|
+
return max(candidates, default=(None, None))[1]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _timestamp(value):
|
|
59
|
+
if not value:
|
|
60
|
+
return None
|
|
61
|
+
try:
|
|
62
|
+
from datetime import datetime
|
|
63
|
+
return datetime.fromisoformat(str(value).replace("Z", "+00:00")).timestamp()
|
|
64
|
+
except ValueError:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _codex_usage(handle):
|
|
69
|
+
latest = None
|
|
70
|
+
for line in handle:
|
|
71
|
+
try:
|
|
72
|
+
record = json.loads(line)
|
|
73
|
+
payload = record.get("payload") or {}
|
|
74
|
+
usage = ((payload.get("info") or {}).get("total_token_usage") or {}) if payload.get("type") == "token_count" else {}
|
|
75
|
+
candidate = _normalize(usage, cached_key="cached_input_tokens", reasoning_key="reasoning_output_tokens")
|
|
76
|
+
if candidate:
|
|
77
|
+
latest = candidate
|
|
78
|
+
except (TypeError, ValueError):
|
|
79
|
+
continue
|
|
80
|
+
return latest
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _claude_usage(handle):
|
|
84
|
+
totals = {"input_tokens": 0, "cached_input_tokens": 0, "output_tokens": 0, "reasoning_tokens": 0}
|
|
85
|
+
seen = set()
|
|
86
|
+
for line in handle:
|
|
87
|
+
try:
|
|
88
|
+
record = json.loads(line)
|
|
89
|
+
message = record.get("message") or {}
|
|
90
|
+
usage = message.get("usage") or {}
|
|
91
|
+
identifier = record.get("uuid")
|
|
92
|
+
if record.get("type") != "assistant" or not usage or (identifier and identifier in seen):
|
|
93
|
+
continue
|
|
94
|
+
if identifier:
|
|
95
|
+
seen.add(identifier)
|
|
96
|
+
totals["input_tokens"] += _number(usage.get("input_tokens"))
|
|
97
|
+
totals["cached_input_tokens"] += _number(usage.get("cache_creation_input_tokens")) + _number(usage.get("cache_read_input_tokens"))
|
|
98
|
+
totals["output_tokens"] += _number(usage.get("output_tokens"))
|
|
99
|
+
except (TypeError, ValueError):
|
|
100
|
+
continue
|
|
101
|
+
if not seen and not any(totals.values()):
|
|
102
|
+
return None
|
|
103
|
+
totals["reasoning_tokens"] = None
|
|
104
|
+
totals["total_tokens"] = totals["input_tokens"] + totals["output_tokens"]
|
|
105
|
+
return totals
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _normalize(usage, cached_key, reasoning_key):
|
|
109
|
+
if not isinstance(usage, dict) or not usage:
|
|
110
|
+
return None
|
|
111
|
+
return {
|
|
112
|
+
"input_tokens": _number(usage.get("input_tokens")),
|
|
113
|
+
"cached_input_tokens": _number(usage.get(cached_key)),
|
|
114
|
+
"output_tokens": _number(usage.get("output_tokens")),
|
|
115
|
+
"reasoning_tokens": _number(usage.get(reasoning_key)) or None,
|
|
116
|
+
"total_tokens": _number(usage.get("total_tokens")) or None,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _number(value):
|
|
121
|
+
try:
|
|
122
|
+
return max(0, int(value or 0))
|
|
123
|
+
except (TypeError, ValueError):
|
|
124
|
+
return 0
|
package/src/run_registry.py
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import os
|
|
3
3
|
import sys
|
|
4
|
-
import tempfile
|
|
5
4
|
import time
|
|
6
5
|
from contextlib import contextmanager
|
|
7
6
|
from datetime import datetime, timezone
|
|
8
7
|
|
|
9
8
|
from .errors import CdxError
|
|
9
|
+
from .fs_utils import atomic_write
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
def utc_now_iso():
|
|
@@ -53,6 +53,25 @@ def _acquire_windows_lock(handle, timeout_seconds):
|
|
|
53
53
|
time.sleep(_LOCK_RETRY_SECONDS)
|
|
54
54
|
|
|
55
55
|
|
|
56
|
+
def _acquire_posix_lock(handle, timeout_seconds):
|
|
57
|
+
"""Acquire a POSIX advisory lock within the same bounded wait as Windows."""
|
|
58
|
+
import fcntl
|
|
59
|
+
|
|
60
|
+
deadline = time.monotonic() + timeout_seconds
|
|
61
|
+
while True:
|
|
62
|
+
try:
|
|
63
|
+
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
64
|
+
return
|
|
65
|
+
except OSError:
|
|
66
|
+
if time.monotonic() >= deadline:
|
|
67
|
+
raise CdxError(
|
|
68
|
+
"Timed out waiting for the run registry lock after "
|
|
69
|
+
f"{timeout_seconds}s. Another cdx process may be stuck holding it.",
|
|
70
|
+
75,
|
|
71
|
+
) from None
|
|
72
|
+
time.sleep(_LOCK_RETRY_SECONDS)
|
|
73
|
+
|
|
74
|
+
|
|
56
75
|
@contextmanager
|
|
57
76
|
def _registry_lock(path, timeout_seconds=REGISTRY_LOCK_TIMEOUT_SECONDS):
|
|
58
77
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
@@ -67,7 +86,7 @@ def _registry_lock(path, timeout_seconds=REGISTRY_LOCK_TIMEOUT_SECONDS):
|
|
|
67
86
|
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
68
87
|
else:
|
|
69
88
|
import fcntl
|
|
70
|
-
|
|
89
|
+
_acquire_posix_lock(handle, timeout_seconds)
|
|
71
90
|
try:
|
|
72
91
|
yield
|
|
73
92
|
finally:
|
|
@@ -89,18 +108,7 @@ def _read_registry(path):
|
|
|
89
108
|
|
|
90
109
|
|
|
91
110
|
def _write_registry(path, data):
|
|
92
|
-
|
|
93
|
-
fd, temp_path = tempfile.mkstemp(prefix=".runs-", suffix=".json", dir=os.path.dirname(path))
|
|
94
|
-
try:
|
|
95
|
-
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
96
|
-
json.dump(data, handle, indent=2, sort_keys=True)
|
|
97
|
-
handle.write("\n")
|
|
98
|
-
os.replace(temp_path, path)
|
|
99
|
-
finally:
|
|
100
|
-
try:
|
|
101
|
-
os.unlink(temp_path)
|
|
102
|
-
except FileNotFoundError:
|
|
103
|
-
pass
|
|
111
|
+
atomic_write(path, json.dumps(data, indent=2, sort_keys=True) + "\n", mode=0o600)
|
|
104
112
|
|
|
105
113
|
|
|
106
114
|
def _is_pid_alive(pid):
|
package/src/run_usage.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import json
|
|
2
2
|
|
|
3
|
-
USAGE_KEYS = ("input_tokens", "output_tokens", "reasoning_tokens", "total_tokens")
|
|
3
|
+
USAGE_KEYS = ("input_tokens", "cached_input_tokens", "output_tokens", "reasoning_tokens", "total_tokens")
|
|
4
4
|
SUPPORTED_PROVIDERS = {"claude", "codex"}
|
|
5
5
|
|
|
6
6
|
|
|
@@ -89,6 +89,12 @@ def _usage_from_dict(value):
|
|
|
89
89
|
usage.get("cache_creation_input_tokens"),
|
|
90
90
|
usage.get("cache_read_input_tokens"),
|
|
91
91
|
)
|
|
92
|
+
cached_input_tokens = _first_int(usage.get("cached_input_tokens"))
|
|
93
|
+
if cached_input_tokens is None:
|
|
94
|
+
cached_input_tokens = _int_value(
|
|
95
|
+
usage.get("cache_creation_input_tokens"),
|
|
96
|
+
usage.get("cache_read_input_tokens"),
|
|
97
|
+
)
|
|
92
98
|
output_tokens = _int_value(usage.get("output_tokens"), usage.get("completion_tokens"))
|
|
93
99
|
reasoning_tokens = _int_value(
|
|
94
100
|
usage.get("reasoning_tokens"),
|
|
@@ -102,6 +108,7 @@ def _usage_from_dict(value):
|
|
|
102
108
|
|
|
103
109
|
return {
|
|
104
110
|
"input_tokens": input_tokens,
|
|
111
|
+
"cached_input_tokens": cached_input_tokens,
|
|
105
112
|
"output_tokens": output_tokens,
|
|
106
113
|
"reasoning_tokens": reasoning_tokens,
|
|
107
114
|
"total_tokens": total_tokens,
|