cdx-manager 0.21.6 → 0.23.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.
@@ -72,12 +72,13 @@ def supports_native_background(provider, env=None, spawn_sync=None):
72
72
  1. A delegated run cannot honour the `cdx run --json` contract. The
73
73
  in-house path runs `claude --print --output-format json` and gets a
74
74
  structured result, token usage, and a final payload. `--bg` runs the
75
- interactive TUI; `claude logs <id>` returns an ANSI screen dump. So
76
- usage accounting, the final payload, and the rate-limit classification
77
- that `--failover` depends on all have no source.
78
- 2. Nothing yet drives a delegated run to a terminal status. `agent_terminal_status`
79
- exists but no caller consults the provider, so the record relies on pid
80
- liveness and a blocked agent reads as running indefinitely.
75
+ interactive TUI; `claude logs <id>` returns an ANSI screen dump. Usage
76
+ and the final text are read back from the session transcript, but the
77
+ rate-limit classification that `--failover` depends on has no source.
78
+ 2. A delegated run only reaches a terminal status when a caller reads it:
79
+ `_reconcile_background_runs` in commands/runs.py consults the
80
+ transcript, then `agent_terminal_status`, on `cdx runs`, `run-status`
81
+ and `run-report`. Until one of those runs, the record stays `running`.
81
82
  3. cdx's own launch spec makes the agent block. A hand-run
82
83
  `claude --bg --permission-mode bypassPermissions "..."` reaches
83
84
  `state: done`; the same task launched through cdx sits at
@@ -223,6 +224,7 @@ def read_transcript_outcome(path):
223
224
  if not path:
224
225
  return outcome
225
226
  totals = {"input_tokens": 0, "cache_creation_tokens": 0, "cache_read_tokens": 0, "output_tokens": 0}
227
+ long_ttl = None
226
228
  # This path had no de-duplication at all, so a transcript that records the
227
229
  # same billed response more than once inflated the total outright.
228
230
  counted = set()
@@ -268,10 +270,13 @@ def read_transcript_outcome(path):
268
270
  totals["cache_creation_tokens"] += usage.get("cache_creation_input_tokens") or 0
269
271
  totals["cache_read_tokens"] += usage.get("cache_read_input_tokens") or 0
270
272
  totals["output_tokens"] += usage.get("output_tokens") or 0
273
+ split = usage.get("cache_creation")
274
+ if isinstance(split, dict) and "ephemeral_1h_input_tokens" in split:
275
+ long_ttl = (long_ttl or 0) + (split["ephemeral_1h_input_tokens"] or 0)
271
276
  except OSError:
272
277
  return outcome
273
278
  if seen:
274
- outcome["usage"] = normalize_usage(**totals)
279
+ outcome["usage"] = normalize_usage(**totals, cache_creation_1h_tokens=long_ttl)
275
280
  if len(models) == 1:
276
281
  outcome["usage_model"] = next(iter(models))
277
282
  return outcome
@@ -150,6 +150,10 @@ CLAUDE_PARENT_SESSION_ENV = (
150
150
  "CLAUDE_CODE_MESSAGING_SOCKET",
151
151
  "CLAUDE_CODE_MESSAGING_TOKEN",
152
152
  "CLAUDE_PID",
153
+ # Credentials: a profile launched from inside another profile's session must
154
+ # not inherit that account's token. The profile's own token is set after.
155
+ "CLAUDE_CODE_OAUTH_TOKEN",
156
+ "ANTHROPIC_API_KEY",
153
157
  )
154
158
  CLAUDE_CLI_MODEL_ALIASES = {
155
159
  "claude-sonnet": "sonnet",
@@ -208,6 +212,40 @@ def _shared_tool_cache_overrides(env):
208
212
  }
209
213
 
210
214
 
215
+ def operator_rtk_history_path(env=None):
216
+ """The rtk history database of the operator's real home.
217
+
218
+ rtk files its savings under the data dir of HOME, and a profile launch
219
+ swaps HOME, so each account kept its own history.db and `rtk gain` in the
220
+ operator's shell saw none of what the agents saved. Read from the passwd
221
+ entry rather than HOME, which is already a profile home when cdx runs
222
+ inside a session.
223
+ """
224
+ env = os.environ if env is None else env
225
+ if os.name == "nt":
226
+ base = env.get("LOCALAPPDATA")
227
+ return os.path.join(base, "rtk", "history.db") if base else None
228
+ import pwd
229
+
230
+ try:
231
+ home = pwd.getpwuid(os.getuid()).pw_dir
232
+ except KeyError:
233
+ # A uid without a passwd entry (common in containers): no better
234
+ # answer than HOME.
235
+ home = env.get("HOME") or os.path.expanduser("~")
236
+ if sys.platform == "darwin":
237
+ return os.path.join(home, "Library", "Application Support", "rtk", "history.db")
238
+ data_home = env.get("XDG_DATA_HOME") or os.path.join(home, ".local", "share")
239
+ return os.path.join(data_home, "rtk", "history.db")
240
+
241
+
242
+ def _shared_rtk_history(env):
243
+ """Point every profile's rtk at one history, unless the operator chose one."""
244
+ path = None if env.get("RTK_DB_PATH") else operator_rtk_history_path(env)
245
+ if path:
246
+ env["RTK_DB_PATH"] = path
247
+
248
+
211
249
  def _anthropic_profile_name():
212
250
  return "default"
213
251
 
@@ -542,6 +580,9 @@ def _wrap_launch_with_transcript(session, spec, capture_transcript=True, env=Non
542
580
  script_args = env.get("CDX_SCRIPT_ARGS")
543
581
  transcript_path = _build_launch_transcript_path(session)
544
582
  os.makedirs(os.path.dirname(transcript_path), exist_ok=True)
583
+ if os.name != "nt":
584
+ # script(1) keeps an existing file's mode, and a transcript can hold a token.
585
+ os.close(os.open(transcript_path, os.O_CREAT | os.O_WRONLY, 0o600))
545
586
  if script_args:
546
587
  args = shlex.split(script_args)
547
588
  if "{transcript}" in args:
@@ -674,12 +715,15 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
674
715
  env = {**os.environ, **env_override}
675
716
  for key, value in _shared_tool_cache_overrides(env).items():
676
717
  env.setdefault(key, value)
718
+ _shared_rtk_history(env)
677
719
  env.update(launch_notify_env(session, notifications_enabled(session)))
678
720
  env.update(_scrub_env(session))
679
721
  preference_args = _launch_preference_args(session, env=env)
680
722
  if session["provider"] == PROVIDER_CLAUDE:
681
723
  launch = session.get("launch") or {}
682
- args = ["--name", session["name"]]
724
+ # No --name: it would title every conversation after the account in
725
+ # the /resume picker. The account already shows in cdx's terminal title.
726
+ args = []
683
727
  conversation_id = _conversation_id(session)
684
728
  if conversation_id:
685
729
  args += ["--session-id", conversation_id]
@@ -776,6 +820,18 @@ def _conversation_is_resumable(session, provider, conversation_id):
776
820
  return False
777
821
 
778
822
 
823
+ def with_requested_conversation(session, conversation_id):
824
+ """The session as if `conversation_id` were the one it last opened.
825
+
826
+ Returns None when that conversation does not exist in this session's
827
+ profile: an explicitly named resume must fail, not quietly fall back to
828
+ --last/--continue and open a different conversation.
829
+ """
830
+ if not _conversation_is_resumable(session, session.get("provider"), conversation_id):
831
+ return None
832
+ return {**session, "conversation": {"id": conversation_id, "provenance": "requested"}}
833
+
834
+
779
835
  def _redacted_resume_command_preview(session, cwd=None):
780
836
  capability = get_resume_capability(session, cwd=cwd)
781
837
  return capability.get("command_preview")
@@ -850,6 +906,7 @@ def _build_resume_spec(session, cwd=None, env_override=None, capture_transcript=
850
906
  cwd = cwd or os.getcwd()
851
907
  env_override = env_override or {}
852
908
  env = {**os.environ, **env_override}
909
+ _shared_rtk_history(env)
853
910
  capability = get_resume_capability(session, cwd=cwd)
854
911
  if not capability["resumable"]:
855
912
  raise CdxError(f"Provider {session.get('provider')} does not support native resume through cdx.")
@@ -869,7 +926,7 @@ def _build_resume_spec(session, cwd=None, env_override=None, capture_transcript=
869
926
  if capability["strategy"] == "provider_conversation_id"
870
927
  else ["--continue"]
871
928
  )
872
- args += ["--name", session["name"]]
929
+ # No --name: on a resume it would overwrite the user's /rename.
873
930
  if launch.get("model"):
874
931
  args += ["--model", _claude_cli_model(launch["model"])]
875
932
  args += _launch_config_args(session)
@@ -926,6 +983,7 @@ def _build_headless_launch_spec(session, cwd=None, env_override=None, initial_pr
926
983
  _validate_initial_prompt(initial_prompt)
927
984
  cwd = cwd or os.getcwd()
928
985
  env = {**os.environ, **(env_override or {})}
986
+ _shared_rtk_history(env)
929
987
  # Headless runs read the same home, and so the same hooks, as an interactive
930
988
  # launch. Their caller already learns of completion from the return value.
931
989
  env.update(launch_notify_env(session, enabled=False))
@@ -937,7 +995,7 @@ def _build_headless_launch_spec(session, cwd=None, env_override=None, initial_pr
937
995
  model = launch.get("model")
938
996
 
939
997
  if session["provider"] == PROVIDER_CLAUDE:
940
- args = ["--print", "--output-format", "json", "--name", session["name"]]
998
+ args = ["--print", "--output-format", "json"]
941
999
  conversation_id = _conversation_id(session)
942
1000
  if conversation_id:
943
1001
  args += ["--session-id", conversation_id]
package/src/retro.py ADDED
@@ -0,0 +1,131 @@
1
+ """Session retrospectives: what the user had to correct, so the next session knows it.
2
+
3
+ With `cdx set <name> --retro on`, the end of an interactive session starts a
4
+ detached, read-only `cdx run --kind retro` on a ready account. It reads the
5
+ session's transcript and reports the corrections the user made and the lessons
6
+ worth keeping, each with where it belongs (memory, runbook, skill, CLAUDE.md).
7
+ It changes nothing: `cdx retro` shows the reports, and the user decides.
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+
15
+ #: A session with fewer prompts than this rarely holds a correction worth a model call.
16
+ MIN_USER_PROMPTS = 3
17
+
18
+ PROMPT = """You are writing a retrospective of one finished coding-agent session. Do not modify any file.
19
+
20
+ Transcript (JSONL, possibly large; read it in bounded portions, never all at once): {transcript}
21
+ Project folder: {cwd}
22
+
23
+ Find the moments where the user corrected, redirected or overruled the agent, and the
24
+ non-obvious facts the session had to discover. Ignore routine back-and-forth.
25
+
26
+ Answer in Markdown with exactly these sections:
27
+ ## Corrections
28
+ One bullet per correction: what the agent did, what the user wanted, in one line each.
29
+ ## Lessons
30
+ One bullet per lesson worth keeping for future sessions, each starting with where it belongs:
31
+ `memory:`, `runbook:`, `skill:` or `CLAUDE.md:`, followed by the exact text to add.
32
+ ## Verdict
33
+ One line: "nothing to learn" if both sections are empty, else the single most useful lesson.
34
+ """
35
+
36
+
37
+ def enabled(session):
38
+ return (session.get("launch") or {}).get("retro") is True
39
+
40
+
41
+ def start(session, cwd, transcript, env=None, popen=subprocess.Popen):
42
+ """Start the retro run in the background; returns the argv, or None when skipped."""
43
+ env = {**os.environ, **(env or {})}
44
+ if not enabled(session) or not transcript or not os.path.isfile(transcript):
45
+ return None
46
+ if env.get("CDX_RETRO") == "1" or _user_prompts(transcript) < MIN_USER_PROMPTS:
47
+ return None
48
+ executable = env.get("CDX_BIN") or shutil.which("cdx", path=env.get("PATH")) or "cdx"
49
+ argv = [
50
+ executable, "run", "--provider", session["provider"], "--cwd", cwd, "--kind", "retro",
51
+ "--permission", "read-only", "--power", "low", "--detach", "--json",
52
+ "--prompt", PROMPT.format(transcript=transcript, cwd=cwd),
53
+ ]
54
+ env["CDX_RETRO"] = "1" # a retro never asks for a retro of itself
55
+ try:
56
+ popen(argv, env=env, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
57
+ stderr=subprocess.DEVNULL, start_new_session=True)
58
+ except OSError:
59
+ return None
60
+ return argv
61
+
62
+
63
+ def _user_prompts(transcript):
64
+ """Prompts the user typed: Claude `user` records that are not tool results, Codex `user_message` events.
65
+
66
+ Size is no proxy: one long handoff prompt makes a large transcript of a session
67
+ that never started.
68
+ """
69
+ count = 0
70
+ try:
71
+ with open(transcript, encoding="utf-8", errors="replace") as handle:
72
+ for line in handle:
73
+ if ('"type":"user"' in line and '"tool_result"' not in line) or '"type":"user_message"' in line:
74
+ count += 1
75
+ if count >= MIN_USER_PROMPTS:
76
+ break
77
+ except OSError:
78
+ return 0
79
+ return count
80
+
81
+
82
+ def reports(registry, limit=5):
83
+ """The newest retro runs, newest first, with their text when finished."""
84
+ found = []
85
+ for run in registry.list(limit=200):
86
+ if run.get("kind") != "retro":
87
+ continue
88
+ payload = run.get("final_payload") if isinstance(run.get("final_payload"), dict) else {}
89
+ found.append({"run_id": run.get("run_id"), "status": run.get("status"), "cwd": run.get("cwd"),
90
+ "started_at": run.get("started_at"), "session": run.get("session"),
91
+ "report": _result_text(payload.get("stdout_path"))})
92
+ if len(found) >= limit:
93
+ break
94
+ return found
95
+
96
+
97
+ def _result_text(stdout_path):
98
+ """The answer of a finished run: Claude's `result`, else where to read it."""
99
+ if not stdout_path or not os.path.isfile(stdout_path):
100
+ return None
101
+ try:
102
+ with open(stdout_path, encoding="utf-8") as handle:
103
+ document = json.load(handle)
104
+ except (OSError, ValueError):
105
+ return f"(see {stdout_path})"
106
+ result = document.get("result") if isinstance(document, dict) else None
107
+ return result if isinstance(result, str) else f"(see {stdout_path})"
108
+
109
+
110
+ def handle_retro(rest, ctx):
111
+ from .cli_helpers import _json_success, _write_json
112
+ from .errors import CdxError
113
+ from .run_registry import RunRegistry
114
+
115
+ json_flag = "--json" in rest
116
+ args = [arg for arg in rest if arg != "--json"]
117
+ limit = 5
118
+ if args[:1] == ["--limit"] and len(args) == 2 and args[1].isdigit():
119
+ limit = int(args[1])
120
+ elif args:
121
+ raise CdxError("Usage: cdx retro [--limit N] [--json]")
122
+ found = reports(RunRegistry(ctx["service"]["base_dir"]), limit=limit)
123
+ if json_flag:
124
+ _write_json(ctx, _json_success("retro", f"{len(found)} retro(s)", retros=found))
125
+ return 0
126
+ if not found:
127
+ ctx["out"]("No session retro yet. Turn them on with `cdx set <name> --retro on`.\n")
128
+ return 0
129
+ for item in found:
130
+ ctx["out"](f"## {item['started_at']} · {item['cwd']} · {item['status']}\n{item['report'] or '(still running)'}\n\n")
131
+ return 0
@@ -0,0 +1,68 @@
1
+ """What rtk saved, for the period `cdx stats` reports on.
2
+
3
+ Launches share the operator's rtk history (RTK_DB_PATH), but profiles used before
4
+ that kept their own `history.db` under their home; both are read so older
5
+ savings are not lost. rtk itself does the reading (`rtk gain --daily --format
6
+ json`), so this never depends on its database schema.
7
+ """
8
+
9
+ import glob
10
+ import json
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+ from datetime import datetime
15
+
16
+ from .provider_runtime import operator_rtk_history_path
17
+
18
+ _PROFILE_DBS = (
19
+ ("*", "Library", "Application Support", "rtk", "history.db"),
20
+ ("*", "*-home", "Library", "Application Support", "rtk", "history.db"),
21
+ ("*", ".local", "share", "rtk", "history.db"),
22
+ ("*", "*-home", ".local", "share", "rtk", "history.db"),
23
+ )
24
+
25
+
26
+ def history_databases(base_dir, env=None):
27
+ """Every rtk history this operator's sessions wrote to, the shared one first."""
28
+ env = os.environ if env is None else env
29
+ found = []
30
+ for path in [env.get("RTK_DB_PATH") or operator_rtk_history_path(env)] + [
31
+ match for pattern in _PROFILE_DBS
32
+ for match in sorted(glob.glob(os.path.join(glob.escape(os.path.join(base_dir, "profiles")), *pattern)))
33
+ ]:
34
+ if path and os.path.isfile(path) and os.path.realpath(path) not in {os.path.realpath(p) for p in found}:
35
+ found.append(path)
36
+ return found
37
+
38
+
39
+ def _in_period(day, period):
40
+ for key, keep in (("from", lambda d, edge: d >= edge), ("to", lambda d, edge: d <= edge)):
41
+ edge = (period or {}).get(key)
42
+ if edge and not keep(day, datetime.fromisoformat(edge).date().isoformat()):
43
+ return False
44
+ return True
45
+
46
+
47
+ def rtk_saved(base_dir, period=None, env=None, run=subprocess.run):
48
+ """{commands, input_tokens, saved_tokens, databases} over the period, or None without rtk."""
49
+ env = {**os.environ, **(env or {})}
50
+ rtk = shutil.which("rtk", path=env.get("PATH"))
51
+ databases = history_databases(base_dir, env)
52
+ if not rtk or not databases:
53
+ return None
54
+ totals = {"commands": 0, "input_tokens": 0, "saved_tokens": 0, "databases": 0}
55
+ for database in databases:
56
+ try:
57
+ result = run([rtk, "gain", "--daily", "--format", "json"], env={**env, "RTK_DB_PATH": database},
58
+ capture_output=True, text=True, timeout=15, check=False)
59
+ days = json.loads(result.stdout).get("daily") or []
60
+ except (OSError, ValueError, AttributeError, subprocess.TimeoutExpired):
61
+ continue
62
+ totals["databases"] += 1
63
+ for day in days:
64
+ if isinstance(day, dict) and _in_period(str(day.get("date")), period):
65
+ totals["commands"] += int(day.get("commands") or 0)
66
+ totals["input_tokens"] += int(day.get("input_tokens") or 0)
67
+ totals["saved_tokens"] += int(day.get("saved_tokens") or 0)
68
+ return totals if totals["databases"] else None
@@ -7,7 +7,7 @@ from datetime import datetime, timezone
7
7
 
8
8
  from .errors import CdxError
9
9
  from .fs_utils import atomic_write
10
- from .run_usage import coerce_usage
10
+ from .run_usage import _parse_json_records, coerce_usage
11
11
  from .session_helpers import _process_is_running
12
12
 
13
13
 
@@ -219,15 +219,41 @@ def _base_record(run_id, *, kind, session, provider, model, cwd, artifacts=None)
219
219
  }
220
220
 
221
221
 
222
+ def _check_capacity(data, max_parallel):
223
+ _refresh_stale_runs(data)
224
+ running = sum(1 for run in data["runs"] if run.get("status") == "running")
225
+ if running >= max_parallel:
226
+ raise CdxError(
227
+ f"{running} run(s) already running, the limit is {max_parallel}. "
228
+ "Wait for one to finish (cdx runs --json) or raise --max-parallel.",
229
+ code="too_many_runs",
230
+ )
231
+
232
+
222
233
  class RunRegistry:
223
234
  def __init__(self, base_dir):
224
235
  self.path = registry_path(base_dir)
225
236
 
226
- def start(self, run_id, *, kind, session, provider, model, cwd, artifacts=None):
237
+ def check_capacity(self, max_parallel):
238
+ """Raise too_many_runs now, before a refused run changes any session state.
239
+
240
+ `start()` checks again under the same lock, so a run that slips in
241
+ between is still refused there.
242
+ """
243
+ with _registry_lock(self.path):
244
+ _check_capacity(_read_registry(self.path), max_parallel)
245
+
246
+ def start(self, run_id, *, kind, session, provider, model, cwd, artifacts=None, label=None, max_parallel=None):
227
247
  with _registry_lock(self.path):
228
248
  data = _read_registry(self.path)
229
249
  data["runs"] = [run for run in data["runs"] if run.get("run_id") != run_id]
250
+ if max_parallel:
251
+ # Counted under the lock and without this run's own record, which a
252
+ # detached child writes a second time under the same id.
253
+ _check_capacity(data, max_parallel)
230
254
  record = _base_record(run_id, kind=kind, session=session, provider=provider, model=model, cwd=cwd, artifacts=artifacts)
255
+ if label:
256
+ record["label"] = label
231
257
  data["runs"].insert(0, record)
232
258
  _prune_finished_runs(data)
233
259
  _write_registry(self.path, data)
@@ -341,7 +367,7 @@ class RunRegistry:
341
367
  return run
342
368
  return None
343
369
 
344
- def list(self, limit=20, since=None):
370
+ def list(self, limit=20, since=None, running=False):
345
371
  """Recent runs, newest first.
346
372
 
347
373
  `since` selects every run that *completed* strictly after that moment
@@ -358,6 +384,10 @@ class RunRegistry:
358
384
  if changed:
359
385
  _write_registry(self.path, data)
360
386
  runs = data["runs"]
387
+ if running:
388
+ # Every in-flight run, uncapped: a caller counting live runs against
389
+ # a cap must not be handed a truncated count.
390
+ return [run for run in runs if run.get("status") == "running"]
361
391
  if since is not None:
362
392
  return [run for run in runs if _completed_after(run, since)]
363
393
  return runs[: max(0, int(limit or 20))]
@@ -384,6 +414,51 @@ def read_text_file(path, limit=120000):
384
414
  return ""
385
415
 
386
416
 
417
+ def _record_final_message(record):
418
+ """The final assistant text one provider record carries, or None."""
419
+ if not isinstance(record, dict):
420
+ return None
421
+ # Claude: `--output-format json` ends with a `result` envelope.
422
+ if record.get("type") == "result" and isinstance(record.get("result"), str):
423
+ return record["result"]
424
+ # Codex `exec --json`: an `item.completed` event wrapping an agent_message.
425
+ item = record.get("item")
426
+ if isinstance(item, dict) and item.get("type") == "agent_message" and isinstance(item.get("text"), str):
427
+ return item["text"]
428
+ # Older Codex builds wrapped it as {"msg": {"type": "agent_message", "message": ...}}.
429
+ msg = record.get("msg")
430
+ if isinstance(msg, dict) and msg.get("type") == "agent_message" and isinstance(msg.get("message"), str):
431
+ return msg["message"]
432
+ return None
433
+
434
+
435
+ def final_message(run):
436
+ """The run's last assistant message, read the same way for every provider.
437
+
438
+ A `--bg` run records it from the transcript as `final_payload.result`; any
439
+ other run left it in its stdout, in the provider's own event format. None
440
+ when neither holds one (a plain-text provider, a run that died early).
441
+ """
442
+ result = (run.get("final_payload") or {}).get("result")
443
+ if isinstance(result, str) and result.strip():
444
+ return result
445
+ stdout_path = (run.get("artifacts") or {}).get("stdout_path")
446
+ if not stdout_path:
447
+ return None
448
+ try:
449
+ with open(stdout_path, encoding="utf-8", errors="replace") as handle:
450
+ text = handle.read()
451
+ except OSError:
452
+ return None
453
+ if not text.strip():
454
+ return None
455
+ for record in reversed(_parse_json_records(text)):
456
+ message = _record_final_message(record)
457
+ if message is not None:
458
+ return message
459
+ return None
460
+
461
+
387
462
  def build_code_review_report(run_id, final_payload):
388
463
  stdout = read_text_file(final_payload.get("stdout_path") if final_payload else None)
389
464
  parsed = None
package/src/run_usage.py CHANGED
@@ -14,6 +14,11 @@ The definitions below are therefore normative, not a suggestion:
14
14
  natively; Codex does not, so its cache-inclusive count
15
15
  is reduced here rather than left for a reader to guess.
16
16
  cache_creation_tokens Tokens written to the provider's prompt cache.
17
+ cache_creation_1h_tokens
18
+ The part of cache_creation_tokens written with
19
+ Anthropic's one-hour TTL, which costs 2x instead of
20
+ 1.25x. A subset, never added to a total; absent when
21
+ the provider does not report the split.
17
22
  cache_read_tokens Tokens served from it.
18
23
  cached_input_tokens cache_creation + cache_read. Kept because it is a
19
24
  published field of `cdx run --json`, and because the
@@ -31,8 +36,8 @@ The definitions below are therefore normative, not a suggestion:
31
36
  quantity.
32
37
 
33
38
  Cache creation and cache read are separate fields because they cost very
34
- differently -- 1.25x and 0.1x of uncached input -- and a weighted figure cannot
35
- recover the split once they are summed.
39
+ differently -- 1.25x (2x for a one-hour TTL) and 0.1x of uncached input -- and
40
+ a weighted figure cannot recover the split once they are summed.
36
41
 
37
42
  Absence stays absent: `None` means "not reported", which is not zero. Only
38
43
  `normalize_usage` may derive `cached_input_tokens` and `total_tokens`, so no
@@ -49,6 +54,7 @@ USAGE_KEYS = (
49
54
  "input_tokens",
50
55
  "cached_input_tokens",
51
56
  "cache_creation_tokens",
57
+ "cache_creation_1h_tokens",
52
58
  "cache_read_tokens",
53
59
  "output_tokens",
54
60
  "reasoning_tokens",
@@ -64,7 +70,7 @@ def empty_usage():
64
70
 
65
71
 
66
72
  def normalize_usage(input_tokens=None, cache_creation_tokens=None, cache_read_tokens=None,
67
- output_tokens=None, reasoning_tokens=None):
73
+ output_tokens=None, reasoning_tokens=None, cache_creation_1h_tokens=None):
68
74
  """Build a usage record from the four measured quantities.
69
75
 
70
76
  The derived fields are computed here and only here. A record whose parts are
@@ -80,6 +86,11 @@ def normalize_usage(input_tokens=None, cache_creation_tokens=None, cache_read_to
80
86
  }
81
87
  if all(value is None for value in parts.values()):
82
88
  return empty_usage()
89
+ long_ttl = _non_negative(cache_creation_1h_tokens)
90
+ if long_ttl is not None:
91
+ # A subset of the writes; more than the writes is not a subset.
92
+ long_ttl = min(long_ttl, parts["cache_creation_tokens"] or 0)
93
+ parts["cache_creation_1h_tokens"] = long_ttl
83
94
  cached = _sum_present(parts["cache_creation_tokens"], parts["cache_read_tokens"])
84
95
  total = _sum_present(
85
96
  parts["input_tokens"],
@@ -94,12 +105,13 @@ def normalize_usage(input_tokens=None, cache_creation_tokens=None, cache_read_to
94
105
  #:
95
106
  #: Defaults for rows without an explicit cache-read price. Newer models can
96
107
  #: discount reads further; both stats and currency use the table's rate.
97
- #: Anthropic's one-hour cache TTL costs 2x instead; nothing in a transcript
98
- #: says which TTL was used, so long-TTL writes are under-weighted. Cache writes
99
- #: are a small share of a cache-heavy session, so that error is bounded --
100
- #: stated here rather than hidden behind a single averaged number.
108
+ #: Anthropic's one-hour cache TTL costs 2x instead. Claude transcripts report
109
+ #: that split as `cache_creation.ephemeral_1h_input_tokens`; Claude Code writes
110
+ #: most of its cache that way, so pricing every write at 1.25x understated
111
+ #: them by up to 1.6x. A record without the split keeps the 1.25x rate.
101
112
  CACHE_READ_MULTIPLIER = 0.1
102
113
  CACHE_WRITE_MULTIPLIER = 1.25
114
+ CACHE_WRITE_1H_MULTIPLIER = 2.0
103
115
 
104
116
  #: What an output token costs relative to an uncached input one, when the model
105
117
  #: is unknown. Current model ratios vary, so an unknown model is priced at the
@@ -144,7 +156,8 @@ def weighted_usage(usage, model=None, prices=None):
144
156
  That is not the ranking anyone reading `cdx stats` is looking for.
145
157
 
146
158
  Output and cache-read ratios come from the model that served the run.
147
- Cache writes retain the five-minute TTL assumption.
159
+ Cache writes use the one-hour rate for the part the provider reported as
160
+ one-hour, the five-minute rate for the rest.
148
161
 
149
162
  `reasoning_tokens` is deliberately excluded: Codex reports it as a subset
150
163
  of its output, and counting it would bill those tokens twice.
@@ -154,12 +167,13 @@ def weighted_usage(usage, model=None, prices=None):
154
167
  weights = {
155
168
  "input_tokens": 1.0,
156
169
  "cache_creation_tokens": CACHE_WRITE_MULTIPLIER,
170
+ "cache_creation_1h_tokens": CACHE_WRITE_1H_MULTIPLIER - CACHE_WRITE_MULTIPLIER,
157
171
  "cache_read_tokens": cache_read_multiplier(model, prices),
158
172
  "output_tokens": output_multiplier(model, prices),
159
173
  }
160
174
  present = []
161
175
  for key, weight in weights.items():
162
- value = usage.get(key)
176
+ value = _long_ttl_writes(usage) if key == "cache_creation_1h_tokens" else usage.get(key)
163
177
  if value is not None:
164
178
  if weight is None and value:
165
179
  return None
@@ -297,9 +311,20 @@ def estimate_cost(usage, model, prices=None):
297
311
  ]
298
312
  if not present:
299
313
  return None
314
+ long_ttl = _long_ttl_writes(usage) or 0
315
+ present.append(rate["input"] * (CACHE_WRITE_1H_MULTIPLIER - CACHE_WRITE_MULTIPLIER) * long_ttl)
300
316
  return sum(present) / 1_000_000
301
317
 
302
318
 
319
+ def _long_ttl_writes(usage):
320
+ """One-hour cache writes, bounded by the writes they are a part of."""
321
+ long_ttl = usage.get("cache_creation_1h_tokens")
322
+ if not isinstance(long_ttl, (int, float)) or isinstance(long_ttl, bool) or long_ttl <= 0:
323
+ return None
324
+ writes = usage.get("cache_creation_tokens")
325
+ return min(long_ttl, writes) if isinstance(writes, (int, float)) and not isinstance(writes, bool) else None
326
+
327
+
303
328
  def claude_usage_dedup_key(record):
304
329
  """What identifies one *billed* Claude API response in a transcript.
305
330
 
@@ -362,6 +387,7 @@ def coerce_usage(usage):
362
387
  cache_read_tokens=usage.get("cache_read_tokens"),
363
388
  output_tokens=usage.get("output_tokens"),
364
389
  reasoning_tokens=usage.get("reasoning_tokens"),
390
+ cache_creation_1h_tokens=usage.get("cache_creation_1h_tokens"),
365
391
  )
366
392
  if record["cached_input_tokens"] is None:
367
393
  # A legacy record carrying only the fused figure: keep it visible rather
@@ -493,6 +519,7 @@ def _usage_from_dict(value):
493
519
  return normalize_usage(
494
520
  input_tokens=input_tokens,
495
521
  cache_creation_tokens=cache_creation,
522
+ cache_creation_1h_tokens=_nested_int(usage, "cache_creation", "ephemeral_1h_input_tokens"),
496
523
  cache_read_tokens=cache_read,
497
524
  output_tokens=_int_value(usage.get("output_tokens"), usage.get("completion_tokens")),
498
525
  reasoning_tokens=_int_value(