cdx-manager 0.24.8 → 0.25.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.
@@ -4,7 +4,7 @@ import sys
4
4
  from .cli_args import ARG_CODE_INVALID_VALUE
5
5
  from .errors import CdxArgumentError, CdxError
6
6
  from .provider_runtime import headless_permission_disables_network
7
- from .run_usage import empty_usage, extract_run_observation
7
+ from .run_usage import empty_usage, estimate_cost, extract_run_observation
8
8
 
9
9
  PROMPT_STDIN = "-"
10
10
 
@@ -55,6 +55,14 @@ def run_cdx_error_code(error):
55
55
  return "cdx_error"
56
56
 
57
57
 
58
+ def run_conversation_id(session, run_info=None):
59
+ if run_info is not None and "conversation_id" in run_info:
60
+ return run_info["conversation_id"]
61
+ if (session or {}).get("provider") == "codex" and not session.get("continue_run"):
62
+ return None
63
+ return ((session or {}).get("conversation") or {}).get("id")
64
+
65
+
58
66
  def run_payload_reasoning_effort(parsed, session):
59
67
  launch = (session.get("launch") or {}) if session else {}
60
68
  return (
@@ -149,7 +157,7 @@ def run_warnings(parsed, session, selection_row=None):
149
157
 
150
158
 
151
159
  def run_launch_payload(api_schema_version, parsed, session, artifacts, cwd=None,
152
- pid=None, launch_log_path=None, selection_row=None):
160
+ pid=None, launch_log_path=None, selection_row=None, conversation_id=None):
153
161
  """Payload for a detached launch: identity and artifact paths, no outcome.
154
162
 
155
163
  Deliberately shaped like `run_result_payload` minus the fields only a
@@ -161,7 +169,10 @@ def run_launch_payload(api_schema_version, parsed, session, artifacts, cwd=None,
161
169
  "ok": True,
162
170
  "action": "run",
163
171
  "launcher": "cdx",
172
+ "parent_run_id": parsed.get("continue_run"),
173
+ "conversation_id": conversation_id or run_conversation_id(session),
164
174
  "detached": True,
175
+ "budget_usd": parsed.get("budget"),
165
176
  "session": session.get("name") if session else None,
166
177
  "provider": session.get("provider") if session else parsed.get("provider"),
167
178
  "model": parsed.get("model") or ((session.get("launch") or {}).get("model") if session else None),
@@ -208,6 +219,8 @@ def run_result_payload(api_schema_version, ok, parsed, session, run_info=None,
208
219
  "ok": bool(ok),
209
220
  "action": "run",
210
221
  "launcher": "cdx",
222
+ "parent_run_id": parsed.get("continue_run"),
223
+ "conversation_id": run_conversation_id(session, run_info),
211
224
  "session": session.get("name") if session else None,
212
225
  "provider": session.get("provider") if session else parsed.get("provider"),
213
226
  "model": run_info.get("effective_model", parsed.get("model") or ((session.get("launch") or {}).get("model") if session else None)),
@@ -223,11 +236,14 @@ def run_result_payload(api_schema_version, ok, parsed, session, run_info=None,
223
236
  "stderr_path": run_info.get("stderr_path"),
224
237
  "usage": usage,
225
238
  "usage_model": usage_model,
239
+ "cost_usd": run_info.get("cost_usd", estimate_cost(usage, usage_model)),
240
+ "budget_usd": parsed.get("budget"),
226
241
  "warnings": warnings,
227
242
  "error": None if ok else {
228
243
  "source": error_source or "cdx",
229
244
  "code": error_code or "cdx_error",
230
245
  "message": message,
246
+ **({"spent_usd": run_info.get("cost_usd")} if error_code == "budget_exceeded" else {}),
231
247
  "arguments": run_error_arguments(error),
232
248
  "allowed_values": run_error_allowed_values(error),
233
249
  "provider_code": run_info.get("returncode") if error_source == "provider" else None,
@@ -243,7 +243,8 @@ class RunRegistry:
243
243
  with _registry_lock(self.path):
244
244
  _check_capacity(_read_registry(self.path), max_parallel)
245
245
 
246
- def start(self, run_id, *, kind, session, provider, model, cwd, artifacts=None, label=None, max_parallel=None):
246
+ def start(self, run_id, *, kind, session, provider, model, cwd, artifacts=None, label=None, max_parallel=None,
247
+ parent_run_id=None, conversation_id=None, launch=None, reasoning_effort=None):
247
248
  with _registry_lock(self.path):
248
249
  data = _read_registry(self.path)
249
250
  data["runs"] = [run for run in data["runs"] if run.get("run_id") != run_id]
@@ -252,6 +253,8 @@ class RunRegistry:
252
253
  # detached child writes a second time under the same id.
253
254
  _check_capacity(data, max_parallel)
254
255
  record = _base_record(run_id, kind=kind, session=session, provider=provider, model=model, cwd=cwd, artifacts=artifacts)
256
+ record.update(parent_run_id=parent_run_id, conversation_id=conversation_id,
257
+ launch=dict(launch or {}), reasoning_effort=reasoning_effort)
255
258
  if label:
256
259
  record["label"] = label
257
260
  data["runs"].insert(0, record)
@@ -285,6 +288,8 @@ class RunRegistry:
285
288
  continue
286
289
  run["background_path"] = path
287
290
  run["provider_session_id"] = provider_session_id
291
+ if provider_session_id:
292
+ run["conversation_id"] = provider_session_id
288
293
  run["provider_agent_id"] = provider_agent_id
289
294
  _write_registry(self.path, data)
290
295
  return run
@@ -356,6 +361,8 @@ class RunRegistry:
356
361
  run["duration_seconds"] = None
357
362
  if run_info:
358
363
  run["pid"] = run_info.get("pid")
364
+ if run_info.get("launch"):
365
+ run["launch"] = run_info["launch"]
359
366
  run["exit_code"] = run_info.get("returncode")
360
367
  run["artifacts"] = {
361
368
  **(run.get("artifacts") or {}),
@@ -367,8 +374,12 @@ class RunRegistry:
367
374
  # Normalize on the way in, not on the way out: this record is
368
375
  # what `cdx run-status` and `cdx run-report` publish, and it
369
376
  # used to store whichever shape the producing reader emitted.
370
- run["usage"] = coerce_usage(final_payload.get("usage"))
377
+ run["usage"] = coerce_usage(final_payload.get("usage"), provider=run.get("provider"))
371
378
  run["usage_model"] = final_payload.get("usage_model")
379
+ run["conversation_id"] = final_payload.get("conversation_id") or run.get("conversation_id")
380
+ run["parent_run_id"] = final_payload.get("parent_run_id") or run.get("parent_run_id")
381
+ if "reasoning_effort" in final_payload:
382
+ run["reasoning_effort"] = final_payload["reasoning_effort"]
372
383
  run["model"] = final_payload.get("model")
373
384
  run["final_payload"] = final_payload
374
385
  if error:
package/src/run_usage.py CHANGED
@@ -24,6 +24,9 @@ The definitions below are therefore normative, not a suggestion:
24
24
  published field of `cdx run --json`, and because the
25
25
  stats table shows one CACHE column. It is a derived
26
26
  sum, never a source.
27
+ cache_hit_ratio cache_read / (input + cache_creation + cache_read).
28
+ None when input, reads, or writes are unreported (except
29
+ Codex writes), or total input is zero.
27
30
  output_tokens Generated tokens.
28
31
  reasoning_tokens Reasoning counted separately by the provider. Codex
29
32
  reports one; Claude does not and never will -- it bills
@@ -40,7 +43,7 @@ differently -- 1.25x (2x for a one-hour TTL) and 0.1x of uncached input -- and
40
43
  a weighted figure cannot recover the split once they are summed.
41
44
 
42
45
  Absence stays absent: `None` means "not reported", which is not zero. Only
43
- `normalize_usage` may derive `cached_input_tokens` and `total_tokens`, so no
46
+ `normalize_usage` derives the cache sum, total, and hit ratio, so no
44
47
  caller can invent a fourth arithmetic.
45
48
  """
46
49
 
@@ -50,7 +53,7 @@ import math
50
53
  from .errors import CdxError
51
54
 
52
55
  #: Order matters only for readability; every consumer addresses these by name.
53
- USAGE_KEYS = (
56
+ TOKEN_USAGE_KEYS = (
54
57
  "input_tokens",
55
58
  "cached_input_tokens",
56
59
  "cache_creation_tokens",
@@ -60,17 +63,18 @@ USAGE_KEYS = (
60
63
  "reasoning_tokens",
61
64
  "total_tokens",
62
65
  )
63
- #: The two fields `normalize_usage` derives. Never read them from a provider.
64
- DERIVED_USAGE_KEYS = ("cached_input_tokens", "total_tokens")
66
+ USAGE_KEYS = (*TOKEN_USAGE_KEYS, "cache_hit_ratio")
67
+ #: Derived values are never read from a provider.
68
+ DERIVED_USAGE_KEYS = ("cached_input_tokens", "total_tokens", "cache_hit_ratio")
65
69
  SUPPORTED_PROVIDERS = {"claude", "codex"}
66
70
 
67
71
 
68
72
  def empty_usage():
69
- return {key: None for key in USAGE_KEYS}
73
+ return dict.fromkeys(USAGE_KEYS)
70
74
 
71
75
 
72
76
  def normalize_usage(input_tokens=None, cache_creation_tokens=None, cache_read_tokens=None,
73
- output_tokens=None, reasoning_tokens=None, cache_creation_1h_tokens=None):
77
+ output_tokens=None, reasoning_tokens=None, cache_creation_1h_tokens=None, provider=None):
74
78
  """Build a usage record from the four measured quantities.
75
79
 
76
80
  The derived fields are computed here and only here. A record whose parts are
@@ -98,7 +102,50 @@ def normalize_usage(input_tokens=None, cache_creation_tokens=None, cache_read_to
98
102
  parts["cache_read_tokens"],
99
103
  parts["output_tokens"],
100
104
  )
101
- return {**parts, "cached_input_tokens": cached, "total_tokens": total}
105
+ return {**parts, "cached_input_tokens": cached, "total_tokens": total,
106
+ "cache_hit_ratio": cache_hit_ratio(parts, provider=provider)}
107
+
108
+
109
+ def cache_hit_ratio(usage, provider=None):
110
+ """Cache reads divided by all input, or None without a reported read split.
111
+
112
+ Missing writes leave Claude's denominator unknown. Only Codex can recover
113
+ the denominator from its cache-inclusive input total when writes are absent.
114
+ An empty input has no defined hit ratio.
115
+ """
116
+ read = _non_negative(usage.get("cache_read_tokens"))
117
+ uncached = _non_negative(usage.get("input_tokens"))
118
+ writes = _non_negative(usage.get("cache_creation_tokens"))
119
+ if read is None or uncached is None or (writes is None and provider != "codex"):
120
+ return None
121
+ total = uncached + read + (writes or 0)
122
+ return read / total if total else None
123
+
124
+
125
+ def summarize_cache_usage(entries):
126
+ """Nullable measured sums and an input-weighted ratio over covered runs."""
127
+ keys = ("input_tokens", "cache_creation_tokens", "cache_read_tokens", "output_tokens")
128
+ sums = dict.fromkeys(keys)
129
+ reads = inputs = measured = 0
130
+ for entry in entries:
131
+ usage = entry.get("usage") or {}
132
+ if not is_vouched(usage):
133
+ continue
134
+ usage = coerce_usage(usage, provider=entry.get("provider"))
135
+ for key in keys:
136
+ value = usage.get(key)
137
+ if value is not None:
138
+ sums[key] = (sums[key] or 0) + value
139
+ if usage["cache_hit_ratio"] is not None:
140
+ measured += 1
141
+ reads += usage["cache_read_tokens"]
142
+ inputs += usage["input_tokens"] + usage["cache_read_tokens"] + (usage.get("cache_creation_tokens") or 0)
143
+ return {**sums, "cache_hit_ratio": reads / inputs if inputs else None,
144
+ "measured_runs": measured, "measured_input_tokens": inputs, "measured_cache_read_tokens": reads}
145
+
146
+
147
+ def format_cache_ratio(ratio):
148
+ return "cache n/d" if ratio is None else f"cache {ratio * 100:.0f} %"
102
149
 
103
150
 
104
151
  #: What a cached token costs relative to one uncached input token.
@@ -370,7 +417,7 @@ def is_vouched(usage):
370
417
  return isinstance(usage, dict) and "cache_creation_tokens" in usage
371
418
 
372
419
 
373
- def coerce_usage(usage):
420
+ def coerce_usage(usage, provider=None):
374
421
  """Bring an already-built usage record onto the canonical shape.
375
422
 
376
423
  For boundaries that receive a record rather than measure one -- the run
@@ -382,6 +429,7 @@ def coerce_usage(usage):
382
429
  if not isinstance(usage, dict):
383
430
  return None
384
431
  record = normalize_usage(
432
+ provider=provider,
385
433
  input_tokens=usage.get("input_tokens"),
386
434
  cache_creation_tokens=usage.get("cache_creation_tokens"),
387
435
  cache_read_tokens=usage.get("cache_read_tokens"),
@@ -409,6 +457,20 @@ def _sum_present(*values):
409
457
  return sum(present)
410
458
 
411
459
 
460
+ def extract_run_conversation_id(provider, stdout_path):
461
+ """Read identity from this run's output, never from transcript recency."""
462
+ if not stdout_path:
463
+ return None
464
+ try:
465
+ with open(stdout_path, encoding="utf-8", errors="replace") as handle:
466
+ records = _parse_json_records(handle.read())
467
+ except OSError:
468
+ return None
469
+ key = "thread_id" if provider == "codex" else "session_id"
470
+ ids = {r[key] for r in records if isinstance(r, dict) and isinstance(r.get(key), str) and r[key]}
471
+ return next(iter(ids)) if len(ids) == 1 else None
472
+
473
+
412
474
  def extract_run_usage(provider, stdout_path):
413
475
  return extract_run_observation(provider, stdout_path)[0]
414
476
 
@@ -430,7 +492,7 @@ def extract_run_observation(provider, stdout_path, auth_home=None):
430
492
  if not records:
431
493
  return empty_usage(), None
432
494
 
433
- usage = _extract_usage_from_records(records)
495
+ usage = _extract_usage_from_records(records, provider=provider)
434
496
  if not _has_usage(usage):
435
497
  return empty_usage(), None
436
498
  models = set()
@@ -439,7 +501,7 @@ def extract_run_observation(provider, stdout_path, auth_home=None):
439
501
  continue
440
502
  message = record.get("message")
441
503
  model = record.get("model") or (message.get("model") if isinstance(message, dict) else None)
442
- if isinstance(model, str) and model and _has_usage(_find_usage(record)):
504
+ if isinstance(model, str) and model and _has_usage(_find_usage(record, provider=provider)):
443
505
  models.add(model)
444
506
  model_usage = record.get("modelUsage")
445
507
  if isinstance(model_usage, dict):
@@ -505,33 +567,33 @@ def _parse_json_records(text):
505
567
  return records
506
568
 
507
569
 
508
- def _extract_usage_from_records(records):
570
+ def _extract_usage_from_records(records, provider=None):
509
571
  latest = None
510
572
  for record in records:
511
- candidate = _find_usage(record)
573
+ candidate = _find_usage(record, provider=provider)
512
574
  if _has_usage(candidate):
513
575
  latest = candidate
514
576
  return latest or empty_usage()
515
577
 
516
578
 
517
- def _find_usage(value):
579
+ def _find_usage(value, provider=None):
518
580
  if isinstance(value, dict):
519
- direct = _usage_from_dict(value)
581
+ direct = _usage_from_dict(value, provider=provider)
520
582
  if _has_usage(direct):
521
583
  return direct
522
584
  for child in value.values():
523
- found = _find_usage(child)
585
+ found = _find_usage(child, provider=provider)
524
586
  if _has_usage(found):
525
587
  return found
526
588
  if isinstance(value, list):
527
589
  for child in value:
528
- found = _find_usage(child)
590
+ found = _find_usage(child, provider=provider)
529
591
  if _has_usage(found):
530
592
  return found
531
593
  return empty_usage()
532
594
 
533
595
 
534
- def _usage_from_dict(value):
596
+ def _usage_from_dict(value, provider=None):
535
597
  """Map one provider usage object onto the canonical fields.
536
598
 
537
599
  This used to fold the cache counts into `input_tokens` *and* report them
@@ -544,16 +606,18 @@ def _usage_from_dict(value):
544
606
  return empty_usage()
545
607
 
546
608
  cache_creation = _first_int(usage.get("cache_creation_input_tokens"))
609
+ if cache_creation is None:
610
+ cache_creation = _first_int(usage.get("cache_write_input_tokens"))
547
611
  cache_read = _first_int(usage.get("cache_read_input_tokens"))
548
- if cache_creation is None and cache_read is None:
549
- # Codex reports one cached figure with no creation/read split; it is a
550
- # read by construction, since nothing else could have written it.
612
+ if cache_read is None:
613
+ # Codex's cached input count measures reads, independently of writes.
551
614
  cache_read = _first_int(usage.get("cached_input_tokens"))
552
615
 
553
616
  input_tokens = _int_value(usage.get("input_tokens"), usage.get("prompt_tokens"))
554
617
  input_tokens = _uncached_input(input_tokens, cache_read, usage)
555
618
 
556
619
  return normalize_usage(
620
+ provider=provider,
557
621
  input_tokens=input_tokens,
558
622
  cache_creation_tokens=cache_creation,
559
623
  cache_creation_1h_tokens=_nested_int(usage, "cache_creation", "ephemeral_1h_input_tokens"),
@@ -583,17 +647,18 @@ def _uncached_input(input_tokens, cache_read, usage):
583
647
  Guessing wrong in that direction would silently erase real input tokens,
584
648
  which is the failure this whole definition exists to stop.
585
649
  """
586
- if input_tokens is None or not cache_read:
650
+ if input_tokens is None:
587
651
  return input_tokens
588
652
  if usage.get("cache_creation_input_tokens") is not None:
589
653
  return input_tokens
590
654
  if usage.get("cache_read_input_tokens") is not None:
591
655
  return input_tokens
592
- if usage.get("cached_input_tokens") is None:
656
+ if usage.get("cached_input_tokens") is None and usage.get("cache_write_input_tokens") is None:
593
657
  return input_tokens
594
- if cache_read > input_tokens:
658
+ cached = (cache_read or 0) + (_first_int(usage.get("cache_write_input_tokens")) or 0)
659
+ if cached > input_tokens:
595
660
  return input_tokens
596
- return input_tokens - cache_read
661
+ return input_tokens - cached
597
662
 
598
663
 
599
664
  def _nested_int(value, parent, child):