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.
- package/README.md +28 -1
- package/VERSION +1 -1
- package/changelogs/CHANGELOGS_0_24_8.md +8 -0
- package/changelogs/CHANGELOGS_0_24_9.md +18 -0
- package/changelogs/CHANGELOGS_0_25_0.md +17 -0
- package/changelogs/CHANGELOGS_UNRELEASED.md +7 -0
- package/checksums/release-archives.json +22 -0
- package/docs/cli.md +20 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/cli.py +1 -1
- package/src/cli_args.py +26 -2
- package/src/commands/launch.py +4 -4
- package/src/commands/runs.py +160 -18
- package/src/commands/status.py +49 -7
- package/src/interactive_usage.py +17 -6
- package/src/provider_background.py +8 -1
- package/src/provider_runtime.py +24 -3
- package/src/run_command.py +18 -2
- package/src/run_registry.py +13 -2
- package/src/run_usage.py +89 -24
package/src/run_command.py
CHANGED
|
@@ -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,
|
package/src/run_registry.py
CHANGED
|
@@ -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`
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
|
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
|
|
549
|
-
# Codex
|
|
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
|
|
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
|
-
|
|
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 -
|
|
661
|
+
return input_tokens - cached
|
|
597
662
|
|
|
598
663
|
|
|
599
664
|
def _nested_int(value, parent, child):
|