cdx-manager 0.24.8 → 0.24.9
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 +18 -1
- package/VERSION +1 -1
- package/changelogs/CHANGELOGS_0_24_8.md +8 -0
- package/changelogs/CHANGELOGS_0_24_9.md +18 -0
- package/checksums/release-archives.json +11 -0
- package/docs/cli.md +8 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/cli.py +1 -1
- package/src/cli_args.py +11 -2
- package/src/commands/launch.py +4 -4
- package/src/commands/runs.py +109 -15
- package/src/commands/status.py +48 -7
- package/src/interactive_usage.py +17 -6
- package/src/provider_background.py +8 -1
- package/src/provider_runtime.py +3 -1
- package/src/run_command.py +13 -1
- package/src/run_registry.py +13 -2
- package/src/run_usage.py +89 -24
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<br clear="left"/>
|
|
6
6
|
|
|
7
|
-
[](LICENSE) ](LICENSE)  
|
|
8
8
|
|
|
9
9
|
**The right AI coding account, when you need it.** CDX Manager keeps your Codex and Claude accounts in separate profiles, shows which ones have quota, and helps you pick the next usable session from your terminal.
|
|
10
10
|
|
|
@@ -42,6 +42,23 @@ cdx work
|
|
|
42
42
|
|
|
43
43
|
CDX checks authentication before interactive launches. Handoff keeps the source transcript in place and asks the receiving agent to verify the conversation and workspace before acting. No account switch or prompt submission happens just because you run `cdx next`.
|
|
44
44
|
|
|
45
|
+
## Cache efficiency and fix rounds
|
|
46
|
+
|
|
47
|
+
`cdx history --json` records uncached input, cache reads, cache writes, output tokens, and `usage.cache_hit_ratio` beside the existing token totals. The ratio is cache reads divided by all input tokens, excluding output. Missing provider splits stay `null`, and text views show `cache n/d` instead of claiming a zero hit rate.
|
|
48
|
+
|
|
49
|
+
`cdx stats --json` adds `cache_usage` sums and an input-weighted `cache_hit_ratio` to each session, project (`--by project`), model (`--by model`), and total. `cache_usage` preserves unknown sums as `null`; existing numeric aggregate fields keep their previous behavior. Ratios include only runs with measured input and cache reads. Coverage is explicit in `measured_runs` and `measured_input_tokens`. `run_types.cold` and `run_types.continued` expose counts, sums, and ratios for comparison. Text views show values such as `cache 72 %` next to tokens.
|
|
50
|
+
|
|
51
|
+
Reuse a completed run's provider conversation for another fix round:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
cdx run work --cwd /path/to/repo --prompt "Implement the change" --reasoning-effort high --json
|
|
55
|
+
cdx run --continue-run RUN_ID --cwd /path/to/repo --prompt "Fix the remaining test failure" --json
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`--continue-run` resumes the exact Claude session or Codex thread and pins the previous run's recorded model and effort, even if the profile settings changed. The profile is inferred from the parent; an explicit session or provider must match. An explicit model or effort must also match. Runs and history record `parent_run_id` and `conversation_id`. Detached continuations use the cdx child supervisor with the same pins. Codex continuation usage is measured between exact transcript snapshots before and after the run, since exec resume can report conversation totals. If either boundary is unavailable, per-run usage stays unknown with `usage_attribution: "continuation_usage_unavailable"` in history instead of counting earlier turns again.
|
|
59
|
+
|
|
60
|
+
Conservative assumption: a continuation requires a finished Claude or Codex run, a locally available provider transcript, and recorded model and effort settings. Missing runs, profiles, expired sessions, and unavailable settings produce a clear error; the caller can retry without `--continue-run` for a cold run. `--failover` and `--isolate-workspace` cannot be combined with continuation. Capacity recovery and provider model fallback are disabled for continuations so they cannot silently switch model or account. Provider cache retention remains provider-controlled, so resuming does not guarantee a cache hit.
|
|
61
|
+
|
|
45
62
|
## Codex model capacity recovery
|
|
46
63
|
|
|
47
64
|
`cdx run` (including `--detach`) automatically recovers from Codex's "Selected model is at capacity" failure. It retries the same prompt, profile, and model after 1 and 2 seconds, then tries the profile's `fallback_model` list in configured order, removing duplicates. Configure alternatives with `cdx set work --fallback-model MODEL_A,MODEL_B`.
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.24.
|
|
1
|
+
0.24.9
|
|
@@ -10,6 +10,14 @@ This patch improves recovery from Codex model capacity failures. (#67)
|
|
|
10
10
|
- Exclude disabled, logged-out, `never`, and `ask-first` profiles from automatic fallback. Limit recovery to three attempts per model and four profile switches, reporting `capacity_recovery_exhausted` when no eligible alternative remains or the budget is exhausted. (#67)
|
|
11
11
|
- Log recovery actions to stderr and structured run data, preserve failed attempt output, and report the effective model without changing profile settings. (#67)
|
|
12
12
|
|
|
13
|
+
## Token efficiency
|
|
14
|
+
|
|
15
|
+
- Record per-run cache hit ratios beside the existing cache read, cache write, uncached input, output, and total token fields. Preserve missing provider splits as `null` in headless, interactive, and background usage readers.
|
|
16
|
+
- Add nullable `cache_usage` sums, input-weighted ratios, measurement coverage, and cold versus continued comparisons to stats by session, project, and model. Display `cache 72 %` or `cache n/d` beside tokens without changing existing JSON fields.
|
|
17
|
+
- Add `cdx run --continue-run RUN_ID` for native Claude and Codex conversation resume with pinned model and reasoning effort, including detached runs. Persist parent links and exact conversation identities. Refuse missing, mismatched, expired, or unpinnable parents; prevent model/account fallback during a continuation.
|
|
18
|
+
- Measure Codex continuation usage from exact transcript boundaries so cumulative conversation totals are not billed again. Parse cache write counts when Codex reports them.
|
|
19
|
+
- Add provider parsing, JSON compatibility, aggregation, continuation validation, settings pinning, refusal, and parent-link regression tests.
|
|
20
|
+
|
|
13
21
|
## Maintenance
|
|
14
22
|
|
|
15
23
|
- Record tray companion checksums for v0.24.7. (`16938c0d22de4589f5b4757fd7ff55e848127e7d`)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# CDX Manager 0.24.9
|
|
2
|
+
|
|
3
|
+
Release date: 2026-10-30
|
|
4
|
+
|
|
5
|
+
This patch tracks cache efficiency and resumes runs with pinned settings. (#69)
|
|
6
|
+
|
|
7
|
+
## Cache efficiency and run continuation
|
|
8
|
+
|
|
9
|
+
- Record per-run cache hit ratios alongside cache read, cache write, uncached input, output, and total token counts. Preserve unknown provider splits as `null`. (#69)
|
|
10
|
+
- Add nullable `cache_usage` summaries, input-weighted cache hit ratios, measurement coverage, and cold versus continued run comparisons to stats by session, project, and model. Show cache efficiency beside token counts in history and stats. (#69)
|
|
11
|
+
- Add `cdx run --continue-run RUN_ID` to resume the exact Claude or Codex conversation from a finished run with its recorded profile, model, and reasoning effort, including detached runs. Preserve parent run links and provider conversation identities. (#69)
|
|
12
|
+
- Refuse unavailable parents, missing transcripts, mismatched profiles, and incompatible settings. Keep continuation settings pinned by disabling automatic capacity recovery and model fallback, and rejecting `--failover` and `--isolate-workspace`. (#69)
|
|
13
|
+
- Attribute Codex continuation usage to exact transcript boundaries so earlier turns are not counted again. Leave per-run usage unknown when those boundaries are unavailable, and parse cache write counts when reported. (#69)
|
|
14
|
+
|
|
15
|
+
## Maintenance
|
|
16
|
+
|
|
17
|
+
- Record tray companion checksums for v0.24.8. (`2f1bb93db481ff6f06d8c61435a228ae9de9c4be`)
|
|
18
|
+
- Record release archive checksums for v0.24.8. (`7ff1b1cc0b11016adc5c1a30135f1553af3a53d7`)
|
|
@@ -661,6 +661,17 @@
|
|
|
661
661
|
"x86_64-pc-windows-msvc": "41df35daa664802b87c052d6a9f634b240eb4e4a915868f1dd629b6734eab76a",
|
|
662
662
|
"x86_64-unknown-linux-musl": "2492e137e5c883fa16f502843a867e0325744dfc66d7d38be2c00eb362425af6"
|
|
663
663
|
}
|
|
664
|
+
},
|
|
665
|
+
"v0.24.8": {
|
|
666
|
+
"github_tarball_sha256": "4e34a7c7411cca9bfe568b07dc997c0d992d6c97806dbf091558cf2a826d6c1c",
|
|
667
|
+
"github_zip_sha256": "ec3f52aba6d645e1ab8990d763b4b761703e1e455f8bf40b58d6a47a01374d66",
|
|
668
|
+
"tray_assets": {
|
|
669
|
+
"aarch64-apple-darwin": "87a3abf4bf9f024ed1dfad74e6b2fe0734b9ae5811e4f6d93da7bdcab02fad66",
|
|
670
|
+
"aarch64-unknown-linux-musl": "32e7481d86be156e6c5e94d25473ff595e2596686c81ebdeff4cb719b993c7d2",
|
|
671
|
+
"x86_64-apple-darwin": "644a0a60b7d740c0853e426f69021cdc323b01438e0bcdf5f2792e8b9e3005c3",
|
|
672
|
+
"x86_64-pc-windows-msvc": "6df3f25ab6a9aa02a45aa6f90f95c3484553b248175394af9ecfd12668479281",
|
|
673
|
+
"x86_64-unknown-linux-musl": "ea867d352a18db59e882d6abbdbafa61b54fbec7fafa66746852696cf18cd6c5"
|
|
674
|
+
}
|
|
664
675
|
}
|
|
665
676
|
}
|
|
666
677
|
}
|
package/docs/cli.md
CHANGED
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
| `cdx agents [--max-age-hours N] [--json]` | Each session's last known agent state (working, waiting, done, failed, limit, ended), waiting first; records older than N hours (default 24) are dropped. See [Agent state](sessions-and-notifications.md#agent-state) |
|
|
57
57
|
| `cdx next [--json] [--refresh]` | Select the best next assistant using the same priority logic as `cdx status` |
|
|
58
58
|
| `cdx select --provider PROVIDER [--min-reasoning-effort minimal\|low\|medium\|high\|xhigh] [--min-power minimal\|low\|medium\|high\|xhigh] [--require-ready] [--refresh] --json` | Select a suitable session for headless automation |
|
|
59
|
-
| `cdx run [session] --cwd PATH (--prompt-file PATH\|--prompt TEXT\|--prompt-file -) [--provider PROVIDER] [--model MODEL] [--kind assistant\|code-review\|retro] [--reasoning-effort minimal\|low\|medium\|high\|xhigh] [--power minimal\|low\|medium\|high\|xhigh] [--permission MODE] [--timeout-seconds N] [--label TEXT] [--max-parallel N] [--detach] [--failover] [--refresh] --json` | Run one headless task and return a stable JSON result; `--detach` returns the `run_id` at launch without waiting, `--failover` continues the task on the next account when this one hits its rate limit (with `--detach` too: the detached cdx child applies it and the run's `occupancies` record each move), `--prompt-file -` reads the prompt from stdin, `--label` names the run in `cdx runs`, `--max-parallel N` (default `CDX_MAX_RUNS`; unset, invalid, 0 or negative means no limit) refuses to start with `too_many_runs` when N runs are already running, `--kind` (default `assistant`) labels the run record and `code-review` adds a review report to its final payload |
|
|
59
|
+
| `cdx run [session] --cwd PATH (--prompt-file PATH\|--prompt TEXT\|--prompt-file -) [--provider PROVIDER] [--model MODEL] [--kind assistant\|code-review\|retro] [--reasoning-effort minimal\|low\|medium\|high\|xhigh] [--power minimal\|low\|medium\|high\|xhigh] [--permission MODE] [--timeout-seconds N] [--label TEXT] [--max-parallel N] [--continue-run RUN_ID] [--detach] [--failover] [--refresh] --json` | Run one headless task and return a stable JSON result; `--detach` returns the `run_id` at launch without waiting, `--failover` continues the task on the next account when this one hits its rate limit (with `--detach` too: the detached cdx child applies it and the run's `occupancies` record each move), `--prompt-file -` reads the prompt from stdin, `--label` names the run in `cdx runs`, `--max-parallel N` (default `CDX_MAX_RUNS`; unset, invalid, 0 or negative means no limit) refuses to start with `too_many_runs` when N runs are already running, `--kind` (default `assistant`) labels the run record and `code-review` adds a review report to its final payload |
|
|
60
60
|
| `cdx run-report <run_id> --json` | Full report, transcript metadata, and final payload for a run; `final_message` is the run's last assistant message, read from Claude's `result` envelope or Codex's last `agent_message` (null when neither exists) |
|
|
61
61
|
| `cdx run-status <run_id> --json` | Status of one run by id |
|
|
62
62
|
| `cdx run-tail <run_id> [--lines N] --json` | Last lines of a run's own output, while it is still running or after it finished |
|
|
@@ -241,6 +241,10 @@ The result includes `launcher: "cdx"`, `run_id`, selected `session`, `provider`,
|
|
|
241
241
|
|
|
242
242
|
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, Claude reads its assistant usage records, and Ollama — which writes no transcript of its own — is launched with `--verbose` so its per-response token counts land in the terminal capture cdx already takes. Antigravity reports no usage: its history is schema-less binary protobuf and its logs carry no usage line, so those sessions show no tokens by nature rather than by omission.
|
|
243
243
|
|
|
244
|
+
`--continue-run RUN_ID` resumes the exact provider conversation from a finished Claude or Codex run, inferring its profile and pinning its recorded model and effort. Existing targets and settings may be supplied only if they match. The parent transcript must still exist. `parent_run_id` links the new run to its parent in run results, the registry, and history; `conversation_id` identifies the provider conversation. Detached continuations preserve these settings through the cdx supervisor. Codex continuation usage is measured between exact transcript snapshots before and after the run, since exec resume can report conversation totals. If either boundary is unavailable, per-run usage stays unknown with `usage_attribution: "continuation_usage_unavailable"` in history instead of counting earlier turns again. Continuation does not use automatic capacity recovery or model fallback, and cannot be combined with `--failover` or `--isolate-workspace`.
|
|
245
|
+
|
|
246
|
+
Continuation errors are `continue_run_not_found` (unknown parent), `continue_run_mismatch` (another or missing profile/provider), `continue_run_unavailable` (unfinished or unsupported run, expired transcript, or unavailable settings), and `continue_run_settings_mismatch` (changed model/effort or incompatible flags). Callers may start a normal run after a refusal by omitting `--continue-run`.
|
|
247
|
+
|
|
244
248
|
Every usage record, whichever path produced it, uses one set of definitions:
|
|
245
249
|
|
|
246
250
|
| Field | Meaning |
|
|
@@ -251,11 +255,14 @@ Every usage record, whichever path produced it, uses one set of definitions:
|
|
|
251
255
|
| `cache_read_tokens` | Tokens served from it. Kept apart from cache creation because the two cost roughly 1.25x and 0.1x of uncached input, a difference no summed figure can recover. |
|
|
252
256
|
| `cached_input_tokens` | `cache_creation_tokens + cache_read_tokens`, derived. The single CACHE column in `cdx stats` shows this. |
|
|
253
257
|
| `output_tokens` | Generated tokens. |
|
|
258
|
+
| `cache_hit_ratio` | Cache reads divided by uncached input plus cache writes plus cache reads, excluding output. A fraction from 0 to 1; `null` without a reported read split or with zero input. When Codex omits writes, its reported total input is uncached input plus reads. |
|
|
254
259
|
| `reasoning_tokens` | Reasoning the provider counts separately. Codex reports one; Claude does not, because it bills thinking as output — those tokens are already inside `output_tokens`, so an empty REASON column for Claude is correct rather than missing. |
|
|
255
260
|
| `total_tokens` | `input + cache_creation + cache_read + output`. Cache is typically the bulk of real consumption, so a total that left it out would not be slightly low — it would be a different quantity. |
|
|
256
261
|
|
|
257
262
|
Cached input is counted once: it is excluded from `input_tokens` and reported in the cache fields, never in both. `null` means the provider did not report the figure, which is not the same as zero.
|
|
258
263
|
|
|
264
|
+
History and stats text show `cache 72 %` beside tokens, or `cache n/d` for unknown splits. Stats JSON adds `cache_usage` with nullable sums for `input_tokens`, `cache_creation_tokens`, `cache_read_tokens`, and `output_tokens`. Existing numeric aggregate fields are unchanged. `cache_hit_ratio` is computed from summed reads and input across measured runs, never by averaging percentages. `measured_runs`, `measured_input_tokens`, and `measured_cache_read_tokens` describe ratio coverage, excluding missing splits and zero-input runs. Each session, project, model, and total includes `run_types.cold` and `run_types.continued`, each with `runs` and the same cache summary fields. A missing `parent_run_id` counts as cold for historical compatibility.
|
|
265
|
+
|
|
259
266
|
`cdx stats` also shows a **`COST~`** column and ranks sessions by it. It weights each token class by what it costs relative to one uncached input token — cache read at the recorded model's ratio (0.1x by default), cache write 1.25x (2x for one-hour writes), and output at the recorded model's output/input ratio — and reports the result in uncached-input-equivalent tokens. These are ratios rather than prices. It is **not** a currency amount, and it is not comparable across providers whose own prices differ. Writes reported as one-hour are weighted 2x; a record without that split keeps the five-minute 1.25x rate.
|
|
260
267
|
|
|
261
268
|
Ranking on the raw total would put a session that mostly replayed a cache above one that spent far more on generation, because cache reads are typically the overwhelming majority of tokens consumed while costing a fiftieth of an output token.
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
package/src/cli.py
CHANGED
|
@@ -245,7 +245,7 @@ def _print_help(use_color=False):
|
|
|
245
245
|
f" {_style('cdx reset <name> [--yes] [--json]', '36', use_color)}",
|
|
246
246
|
f" {_style('cdx auth refresh <name|all> [--json]', '36', use_color)}",
|
|
247
247
|
f" {_style('cdx select --provider PROVIDER [--min-reasoning-effort minimal|low|medium|high|xhigh] [--min-power minimal|low|medium|high|xhigh] [--require-ready] [--refresh] --json', '36', use_color)}",
|
|
248
|
-
f" {_style('cdx run [session] --cwd PATH (--prompt-file PATH|--prompt TEXT|--prompt-file -) [--provider PROVIDER] [--model MODEL] [--kind assistant|code-review|retro] [--reasoning-effort minimal|low|medium|high|xhigh] [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full|workspace-write|read-only|danger-full-access] [--timeout-seconds N] [--label TEXT] [--max-parallel N] [--detach] [--failover] [--refresh] --json', '36', use_color)}",
|
|
248
|
+
f" {_style('cdx run [session] --cwd PATH (--prompt-file PATH|--prompt TEXT|--prompt-file -) [--provider PROVIDER] [--model MODEL] [--kind assistant|code-review|retro] [--reasoning-effort minimal|low|medium|high|xhigh] [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full|workspace-write|read-only|danger-full-access] [--timeout-seconds N] [--label TEXT] [--max-parallel N] [--continue-run RUN_ID] [--detach] [--failover] [--refresh] --json', '36', use_color)}",
|
|
249
249
|
f" {_style('cdx runs [--limit N] [--since 7d|today|DATE] [--running] --json', '36', use_color)}",
|
|
250
250
|
f" {_style('cdx run-status <run_id> --json', '36', use_color)}",
|
|
251
251
|
f" {_style('cdx run-report <run_id> --json', '36', use_color)}",
|
package/src/cli_args.py
CHANGED
|
@@ -47,7 +47,7 @@ CAN_RESUME_USAGE = "Usage: cdx can-resume <name> [--conversation <id>] [--json]"
|
|
|
47
47
|
SESSIONS_USAGE = "Usage: cdx sessions [--all] --json"
|
|
48
48
|
SELECT_USAGE = "Usage: cdx select --provider PROVIDER [--min-reasoning-effort minimal|low|medium|high|xhigh] [--min-power minimal|low|medium|high|xhigh] [--require-ready] [--refresh] --json"
|
|
49
49
|
NEXT_USAGE = "Usage: cdx next [--json] [--refresh]"
|
|
50
|
-
RUN_USAGE = "Usage: cdx run [session] (--cwd|--dir) PATH (--prompt-file PATH|--prompt TEXT|--prompt-file -) [--provider PROVIDER] [--model MODEL] [--kind assistant|code-review|retro] [--reasoning-effort minimal|low|medium|high|xhigh] [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full|workspace-write|read-only|danger-full-access] [--network-access] [--isolate-workspace] [--timeout-seconds N] [--label TEXT] [--max-parallel N] [--detach] [--failover] [--refresh] --json"
|
|
50
|
+
RUN_USAGE = "Usage: cdx run [session] (--cwd|--dir) PATH (--prompt-file PATH|--prompt TEXT|--prompt-file -) [--provider PROVIDER] [--model MODEL] [--kind assistant|code-review|retro] [--reasoning-effort minimal|low|medium|high|xhigh] [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full|workspace-write|read-only|danger-full-access] [--network-access] [--isolate-workspace] [--timeout-seconds N] [--label TEXT] [--max-parallel N] [--continue-run RUN_ID] [--detach] [--failover] [--refresh] --json"
|
|
51
51
|
RUN_JSON_REQUIRED = "cdx run: --json is required."
|
|
52
52
|
RUN_TARGET_REQUIRED = "cdx run: specify a session name or --provider PROVIDER."
|
|
53
53
|
RUN_SESSION_PROVIDER_CONFLICT = "cdx run: cannot specify both a session name and --provider."
|
|
@@ -544,6 +544,10 @@ def cdx_schema():
|
|
|
544
544
|
"failover_exhausted",
|
|
545
545
|
"failover_limit_reached",
|
|
546
546
|
"capacity_recovery_exhausted",
|
|
547
|
+
"continue_run_not_found",
|
|
548
|
+
"continue_run_mismatch",
|
|
549
|
+
"continue_run_unavailable",
|
|
550
|
+
"continue_run_settings_mismatch",
|
|
547
551
|
"invalid_request",
|
|
548
552
|
"cdx_error",
|
|
549
553
|
],
|
|
@@ -661,6 +665,7 @@ def _parse_run_args(args):
|
|
|
661
665
|
"--prompt": {"key": "prompt", "type": "str", "default": None},
|
|
662
666
|
"--provider": {"key": "provider", "type": "str", "default": None, "transform": _parse_run_provider},
|
|
663
667
|
"--model": {"key": "model", "type": "str", "default": None},
|
|
668
|
+
"--continue-run": {"key": "continue_run", "type": "str", "default": None},
|
|
664
669
|
"--kind": {"key": "kind", "type": "str", "default": "assistant"},
|
|
665
670
|
"--reasoning-effort": {"key": "reasoning_effort", "type": "str", "default": None},
|
|
666
671
|
"--power": {"key": "power", "type": "str", "default": None},
|
|
@@ -684,7 +689,7 @@ def _parse_run_args(args):
|
|
|
684
689
|
code=ARG_CODE_MUTUALLY_EXCLUSIVE,
|
|
685
690
|
arguments=["session", "--provider"],
|
|
686
691
|
)
|
|
687
|
-
if not name and not parsed["provider"]:
|
|
692
|
+
if not name and not parsed["provider"] and not parsed["continue_run"]:
|
|
688
693
|
raise CdxArgumentError(RUN_TARGET_REQUIRED, code=ARG_CODE_MISSING, arguments=["session", "--provider"])
|
|
689
694
|
if not parsed["cwd"]:
|
|
690
695
|
raise CdxArgumentError(RUN_CWD_REQUIRED, code=ARG_CODE_MISSING, arguments=["--cwd"])
|
|
@@ -708,6 +713,9 @@ def _parse_run_args(args):
|
|
|
708
713
|
arguments=["--kind"],
|
|
709
714
|
allowed=RUN_KIND_VALUES,
|
|
710
715
|
)
|
|
716
|
+
if parsed["continue_run"] is not None and not parsed["continue_run"].strip():
|
|
717
|
+
raise CdxArgumentError("cdx run: --continue-run requires a nonempty run id.",
|
|
718
|
+
code=ARG_CODE_INVALID_VALUE, arguments=["--continue-run"])
|
|
711
719
|
effort = _parse_run_effort(parsed["reasoning_effort"], parsed["power"])
|
|
712
720
|
return {
|
|
713
721
|
"name": name,
|
|
@@ -715,6 +723,7 @@ def _parse_run_args(args):
|
|
|
715
723
|
"cwd": parsed["cwd"],
|
|
716
724
|
"prompt_file": parsed["prompt_file"],
|
|
717
725
|
"prompt": parsed["prompt"],
|
|
726
|
+
"continue_run": parsed["continue_run"],
|
|
718
727
|
"kind": parsed["kind"],
|
|
719
728
|
"model": parsed["model"],
|
|
720
729
|
"permission": parsed["permission"],
|
package/src/commands/launch.py
CHANGED
|
@@ -34,7 +34,7 @@ from ..provider_runtime import (
|
|
|
34
34
|
with_requested_conversation,
|
|
35
35
|
)
|
|
36
36
|
from ..run_command import PROMPT_STDIN, read_run_prompt
|
|
37
|
-
from ..run_usage import estimate_cost
|
|
37
|
+
from ..run_usage import estimate_cost, format_cache_ratio
|
|
38
38
|
|
|
39
39
|
LAUNCH_USAGE = "Usage: cdx <name> [--dir PATH] [--prompt TEXT|--prompt-file PATH|--task TEXT [--end-state TEXT]] [--resume] [--on-busy fail|ask] [--json]"
|
|
40
40
|
|
|
@@ -425,9 +425,9 @@ def _goodbye_line(session, run_info, use_color):
|
|
|
425
425
|
if cost is not None:
|
|
426
426
|
parts.append(_format_usd(cost))
|
|
427
427
|
elif isinstance(usage, dict):
|
|
428
|
-
total_tokens =
|
|
428
|
+
total_tokens = usage.get("total_tokens")
|
|
429
429
|
if total_tokens:
|
|
430
|
-
parts.append(f"{_format_token_count(total_tokens)} tokens")
|
|
430
|
+
parts.append(f"{_format_token_count(total_tokens)} tokens {format_cache_ratio(usage.get('cache_hit_ratio'))}")
|
|
431
431
|
return _dim(" · ".join(parts), use_color)
|
|
432
432
|
|
|
433
433
|
|
|
@@ -482,7 +482,7 @@ def _attach_interactive_usage(session, run_info, history=None, baseline=None, un
|
|
|
482
482
|
else:
|
|
483
483
|
previous = _previous_cumulative(history, provider_transcript)
|
|
484
484
|
if previous is not None:
|
|
485
|
-
delta = usage_delta(cumulative, previous)
|
|
485
|
+
delta = usage_delta(cumulative, previous, provider=session.get("provider"))
|
|
486
486
|
if delta is not None:
|
|
487
487
|
run_info["usage"] = delta
|
|
488
488
|
# A None delta means the transcript shrank, rotated, or was replaced.
|
package/src/commands/runs.py
CHANGED
|
@@ -23,7 +23,9 @@ from ..cli_args import (
|
|
|
23
23
|
cdx_schema,
|
|
24
24
|
)
|
|
25
25
|
from ..cli_helpers import API_SCHEMA_VERSION, _json_failure, _json_success, _write_json
|
|
26
|
+
from ..config import split_extra_args
|
|
26
27
|
from ..errors import CdxError
|
|
28
|
+
from ..interactive_usage import MEASURED_KEYS, extract_interactive_usage, usage_delta
|
|
27
29
|
from ..provider_background import (
|
|
28
30
|
BACKGROUND_PATH_CDX,
|
|
29
31
|
BACKGROUND_PATH_PROVIDER,
|
|
@@ -41,9 +43,11 @@ from ..provider_runtime import (
|
|
|
41
43
|
_build_launch_spec,
|
|
42
44
|
_ensure_session_authentication,
|
|
43
45
|
_headless_artifact_paths,
|
|
46
|
+
_launch_power,
|
|
44
47
|
_restore_signal_handlers,
|
|
45
48
|
_route_termination_to_interrupt,
|
|
46
49
|
_run_headless_provider_command,
|
|
50
|
+
with_requested_conversation,
|
|
47
51
|
)
|
|
48
52
|
from ..run_command import read_run_prompt, run_cdx_error_code, run_launch_payload, run_result_payload
|
|
49
53
|
from ..run_failover import (
|
|
@@ -54,7 +58,7 @@ from ..run_failover import (
|
|
|
54
58
|
looks_model_at_capacity,
|
|
55
59
|
)
|
|
56
60
|
from ..run_registry import RunRegistry, build_code_review_report, final_message
|
|
57
|
-
from ..run_usage import extract_run_observation
|
|
61
|
+
from ..run_usage import empty_usage, extract_run_conversation_id, extract_run_observation
|
|
58
62
|
from ..session_ranking import FACTOR_DESCRIPTIONS, rank_sessions, selection_policy
|
|
59
63
|
from ..status_view import _now_timestamp, _priority_reset_timestamp
|
|
60
64
|
|
|
@@ -200,6 +204,7 @@ def _detached_child_argv(parsed, prompt_path, session_name, cwd):
|
|
|
200
204
|
# from the package root, so a relative "--cwd ." would resolve there.
|
|
201
205
|
argv = ["run", session_name, "--cwd", cwd, "--prompt-file", prompt_path]
|
|
202
206
|
for flag, key in (
|
|
207
|
+
("--continue-run", "continue_run"),
|
|
203
208
|
("--model", "model"),
|
|
204
209
|
("--kind", "kind"),
|
|
205
210
|
("--permission", "permission"),
|
|
@@ -537,6 +542,77 @@ def _env_max_runs(ctx):
|
|
|
537
542
|
return limit if limit > 0 else None
|
|
538
543
|
|
|
539
544
|
|
|
545
|
+
def _observe_headless_usage(session, run_info, baseline=None):
|
|
546
|
+
usage, model = extract_run_observation(session.get("provider"), run_info.get("stdout_path"), session.get("authHome"))
|
|
547
|
+
observation = {"usage": usage, "usage_model": model}
|
|
548
|
+
if baseline is not None:
|
|
549
|
+
cumulative, path, match, transcript_model = extract_interactive_usage(
|
|
550
|
+
session["provider"], session.get("authHome"), conversation_id=(session.get("conversation") or {}).get("id"))
|
|
551
|
+
before, before_path, _before_match, _before_model = baseline
|
|
552
|
+
comparable = (cumulative and before and path == before_path
|
|
553
|
+
and all((cumulative.get(key) is None) == (before.get(key) is None) for key in MEASURED_KEYS))
|
|
554
|
+
# A newly reported split includes unknown pre-launch usage; losing a
|
|
555
|
+
# split also prevents a comparable measurement across the boundary.
|
|
556
|
+
delta = usage_delta(cumulative, before, provider=session.get("provider")) if comparable else None
|
|
557
|
+
# Exec resume can emit conversation totals. Only exact launch boundaries
|
|
558
|
+
# establish this run's share; stdout cannot replace a missing boundary.
|
|
559
|
+
observation.update(usage=delta if delta is not None else empty_usage(), usage_cumulative=cumulative,
|
|
560
|
+
usage_attribution="launch_boundary" if delta is not None else "continuation_usage_unavailable",
|
|
561
|
+
provider_transcript_path=path, provider_transcript_match=match,
|
|
562
|
+
usage_model=model or transcript_model)
|
|
563
|
+
return observation
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _continue_run_session(parsed, ctx):
|
|
567
|
+
"""Resolve only the parent's profile and exact persisted conversation."""
|
|
568
|
+
previous = _run_registry(ctx).get(parsed["continue_run"])
|
|
569
|
+
if not previous:
|
|
570
|
+
raise CdxError(f"Cannot continue unknown run: {parsed['continue_run']}. Start a normal run instead.",
|
|
571
|
+
code="continue_run_not_found")
|
|
572
|
+
name, provider = previous.get("session"), previous.get("provider")
|
|
573
|
+
if (parsed.get("name") and parsed["name"] != name) or (parsed.get("provider") and parsed["provider"] != provider):
|
|
574
|
+
raise CdxError("Cannot continue a run from another profile or provider. Start a normal run instead.",
|
|
575
|
+
code="continue_run_mismatch")
|
|
576
|
+
session = ctx["service"]["get_session"](name) if name else None
|
|
577
|
+
if not session or session.get("provider") != provider:
|
|
578
|
+
raise CdxError("The previous run's profile or provider is no longer available. Start a normal run instead.",
|
|
579
|
+
code="continue_run_mismatch")
|
|
580
|
+
if provider not in ("claude", "codex") or previous.get("status") not in ("succeeded", "failed", "timed_out", "cancelled"):
|
|
581
|
+
raise CdxError("Only a finished Claude or Codex run can be continued. Start a normal run instead.",
|
|
582
|
+
code="continue_run_unavailable")
|
|
583
|
+
conversation = previous.get("conversation_id")
|
|
584
|
+
resumed = with_requested_conversation(session, conversation)
|
|
585
|
+
if not resumed:
|
|
586
|
+
raise CdxError("The previous run's provider session expired or its transcript is missing. Start a normal run instead.",
|
|
587
|
+
code="continue_run_unavailable")
|
|
588
|
+
model = previous.get("usage_model") or previous.get("model")
|
|
589
|
+
effort = previous.get("reasoning_effort")
|
|
590
|
+
if not isinstance(model, str) or not model or model.startswith("<") or not effort or not previous.get("launch"):
|
|
591
|
+
raise CdxError("The previous run's model and effort cannot be pinned. Start a normal run instead.",
|
|
592
|
+
code="continue_run_unavailable")
|
|
593
|
+
if ((parsed.get("model") and parsed["model"] != model)
|
|
594
|
+
or (parsed.get("reasoning_effort") and parsed["reasoning_effort"] != effort)):
|
|
595
|
+
raise CdxError("--continue-run requires the previous run's model and reasoning effort.",
|
|
596
|
+
code="continue_run_settings_mismatch")
|
|
597
|
+
if parsed.get("failover") or parsed.get("isolate_workspace"):
|
|
598
|
+
raise CdxError("--continue-run cannot be combined with --failover or --isolate-workspace.",
|
|
599
|
+
code="continue_run_settings_mismatch")
|
|
600
|
+
if provider == "claude":
|
|
601
|
+
# Passthrough options are appended last and can override the pinned settings.
|
|
602
|
+
pinned_options = {"--model", "--effort", "--fallback-model", "--resume", "--continue",
|
|
603
|
+
"--session-id", "--fork-session", "--resume-session-at", "--from-pr",
|
|
604
|
+
"--settings", "--agent", "--agents"}
|
|
605
|
+
for arg in split_extra_args((session.get("launch") or {}).get("extra_args")) or []:
|
|
606
|
+
if arg.split("=", 1)[0] in pinned_options or arg.startswith(("-r", "-c")):
|
|
607
|
+
raise CdxError("--continue-run cannot use Claude extra_args that override model, effort, or conversation.",
|
|
608
|
+
code="continue_run_settings_mismatch")
|
|
609
|
+
parsed.update(name=name, model=model, reasoning_effort=effort, power=effort)
|
|
610
|
+
launch = {**(session.get("launch") or {}), "model": model, "reasoning_effort": effort, "power": effort}
|
|
611
|
+
# A provider fallback would break the model pin even though cdx stayed on this profile.
|
|
612
|
+
launch.pop("fallback_model", None)
|
|
613
|
+
return {**resumed, "launch": launch, "continue_run": parsed["continue_run"]}
|
|
614
|
+
|
|
615
|
+
|
|
540
616
|
def handle_run(rest, ctx):
|
|
541
617
|
registry = None
|
|
542
618
|
run_id = None
|
|
@@ -548,8 +624,11 @@ def handle_run(rest, ctx):
|
|
|
548
624
|
detached_run_id = os.environ.pop(DETACHED_RUN_ID_ENV, None) or None
|
|
549
625
|
try:
|
|
550
626
|
parsed = _parse_run_args(rest)
|
|
551
|
-
session = None
|
|
552
|
-
if
|
|
627
|
+
session = _continue_run_session(parsed, ctx) if parsed.get("continue_run") else None
|
|
628
|
+
if session:
|
|
629
|
+
if session.get("enabled", True) is False:
|
|
630
|
+
raise CdxError(f"Session is disabled: {session['name']}", code="session_disabled")
|
|
631
|
+
elif parsed["name"]:
|
|
553
632
|
session = ctx["service"]["get_session"](parsed["name"])
|
|
554
633
|
if not session:
|
|
555
634
|
raise CdxError(f"Unknown session: {parsed['name']}")
|
|
@@ -604,7 +683,8 @@ def handle_run(rest, ctx):
|
|
|
604
683
|
# records its own afterwards, from the rollout. Without this a headless
|
|
605
684
|
# Claude run left the session with no identity and `cdx resume` fell
|
|
606
685
|
# back to recency for Claude while working by id for Codex.
|
|
607
|
-
|
|
686
|
+
if not parsed.get("continue_run"):
|
|
687
|
+
session = ctx["service"]["begin_conversation"](session["name"])
|
|
608
688
|
run_session = {
|
|
609
689
|
**session,
|
|
610
690
|
"launch": {
|
|
@@ -646,6 +726,10 @@ def handle_run(rest, ctx):
|
|
|
646
726
|
},
|
|
647
727
|
label=parsed.get("label"),
|
|
648
728
|
max_parallel=max_parallel,
|
|
729
|
+
parent_run_id=parsed.get("continue_run"),
|
|
730
|
+
conversation_id=(run_session.get("conversation") or {}).get("id") if run_session["provider"] == "claude" or parsed.get("continue_run") else None,
|
|
731
|
+
launch=run_session.get("launch"),
|
|
732
|
+
reasoning_effort=_launch_power(run_session),
|
|
649
733
|
)
|
|
650
734
|
if parsed.get("detach"):
|
|
651
735
|
# Delegate where the installed CLI can both start a background
|
|
@@ -654,7 +738,7 @@ def handle_run(rest, ctx):
|
|
|
654
738
|
launch = None
|
|
655
739
|
# A provider's own background agent has no cdx process watching it,
|
|
656
740
|
# so nothing could fail it over; --failover keeps the in-house child.
|
|
657
|
-
if not parsed.get("isolate_workspace") and not parsed.get("network_access") and not parsed.get("failover") and supports_native_background(
|
|
741
|
+
if not parsed.get("continue_run") and not parsed.get("isolate_workspace") and not parsed.get("network_access") and not parsed.get("failover") and supports_native_background(
|
|
658
742
|
run_session.get("provider"),
|
|
659
743
|
env=ctx.get("env"),
|
|
660
744
|
spawn_sync=ctx.get("spawn_text"),
|
|
@@ -680,6 +764,7 @@ def handle_run(rest, ctx):
|
|
|
680
764
|
cwd=cwd,
|
|
681
765
|
pid=launch.get("pid"),
|
|
682
766
|
launch_log_path=launch.get("launch_log_path"),
|
|
767
|
+
conversation_id=launch.get("provider_session_id"),
|
|
683
768
|
selection_row=selection_row,
|
|
684
769
|
))
|
|
685
770
|
return 0
|
|
@@ -693,6 +778,10 @@ def handle_run(rest, ctx):
|
|
|
693
778
|
capacity_retries = 0
|
|
694
779
|
while True:
|
|
695
780
|
attempt += 1
|
|
781
|
+
baseline = extract_interactive_usage(
|
|
782
|
+
run_session["provider"], run_session.get("authHome"),
|
|
783
|
+
conversation_id=(run_session.get("conversation") or {}).get("id"),
|
|
784
|
+
) if parsed.get("continue_run") and run_session["provider"] == "codex" else None
|
|
696
785
|
run_info = _run_headless_provider_command(
|
|
697
786
|
run_session,
|
|
698
787
|
cwd=cwd,
|
|
@@ -702,11 +791,13 @@ def handle_run(rest, ctx):
|
|
|
702
791
|
spawn=ctx.get("spawn_headless") or ctx.get("spawn"),
|
|
703
792
|
run_id=run_id,
|
|
704
793
|
)
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
794
|
+
observation = _observe_headless_usage(run_session, run_info, baseline)
|
|
795
|
+
run_info = {**run_info, **observation,
|
|
796
|
+
"parent_run_id": parsed.get("continue_run"),
|
|
797
|
+
"conversation_id": extract_run_conversation_id(run_session.get("provider"), run_info.get("stdout_path"))
|
|
798
|
+
or ((run_session.get("conversation") or {}).get("id") if run_session["provider"] == "claude" or parsed.get("continue_run") else None),
|
|
799
|
+
"launch": run_session.get("launch"),
|
|
800
|
+
"reasoning_effort": _launch_power(run_session),
|
|
710
801
|
"effective_model": (run_session.get("launch") or {}).get("model"),
|
|
711
802
|
"recovery_events": recovery_events}
|
|
712
803
|
ctx["service"]["record_launch_history"](run_session["name"], {
|
|
@@ -718,6 +809,8 @@ def handle_run(rest, ctx):
|
|
|
718
809
|
"exit_code": run_info.get("returncode"),
|
|
719
810
|
**run_info,
|
|
720
811
|
})
|
|
812
|
+
if parsed.get("continue_run"):
|
|
813
|
+
break
|
|
721
814
|
if looks_model_at_capacity(run_session.get("provider"), run_info):
|
|
722
815
|
event = {"reason": "model_at_capacity", "attempt": attempt,
|
|
723
816
|
"session": run_session["name"], "model": models[model_index]}
|
|
@@ -883,11 +976,12 @@ def handle_run(rest, ctx):
|
|
|
883
976
|
if run_info is not None:
|
|
884
977
|
run_info = {**run_info, "recovery_events": locals().get("recovery_events", [])}
|
|
885
978
|
if run_info and run_id and not getattr(error, "history_recorded", False):
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
979
|
+
observation = _observe_headless_usage(run_session, run_info, locals().get("baseline"))
|
|
980
|
+
run_info = {**run_info, **observation,
|
|
981
|
+
"parent_run_id": parsed.get("continue_run"),
|
|
982
|
+
"conversation_id": extract_run_conversation_id(run_session.get("provider"), run_info.get("stdout_path"))
|
|
983
|
+
or ((run_session.get("conversation") or {}).get("id") if run_session["provider"] == "claude" or parsed.get("continue_run") else None),
|
|
984
|
+
"launch": run_session.get("launch"), "reasoning_effort": _launch_power(run_session)}
|
|
891
985
|
ctx["service"]["record_launch_history"](run_session["name"], {
|
|
892
986
|
"status": "failed",
|
|
893
987
|
"action": "run",
|
package/src/commands/status.py
CHANGED
|
@@ -53,10 +53,13 @@ from ..provider_runtime import (
|
|
|
53
53
|
)
|
|
54
54
|
from ..rtk_savings import rtk_saved
|
|
55
55
|
from ..run_usage import (
|
|
56
|
-
|
|
56
|
+
TOKEN_USAGE_KEYS,
|
|
57
|
+
cache_hit_ratio,
|
|
57
58
|
estimate_cost,
|
|
59
|
+
format_cache_ratio,
|
|
58
60
|
is_vouched,
|
|
59
61
|
normalize_usage,
|
|
62
|
+
summarize_cache_usage,
|
|
60
63
|
token_prices,
|
|
61
64
|
weighted_usage,
|
|
62
65
|
)
|
|
@@ -294,7 +297,9 @@ def _summarize_stats(entries):
|
|
|
294
297
|
"reasoning_tokens": 0,
|
|
295
298
|
"total_tokens": 0,
|
|
296
299
|
"last_started_at": None,
|
|
300
|
+
"_entries": [],
|
|
297
301
|
})
|
|
302
|
+
row["_entries"].append(entry)
|
|
298
303
|
row["launches"] += 1
|
|
299
304
|
if entry.get("provider"):
|
|
300
305
|
row["provider"].add(entry["provider"])
|
|
@@ -315,7 +320,7 @@ def _summarize_stats(entries):
|
|
|
315
320
|
usage = {}
|
|
316
321
|
parsed_usage = {
|
|
317
322
|
key: _token_value(usage, key)
|
|
318
|
-
for key in
|
|
323
|
+
for key in TOKEN_USAGE_KEYS
|
|
319
324
|
}
|
|
320
325
|
if any(value is not None for value in parsed_usage.values()):
|
|
321
326
|
row["usage_runs"] += 1
|
|
@@ -361,6 +366,16 @@ def _summarize_stats(entries):
|
|
|
361
366
|
# overwhelming majority of cached tokens are, and weighting the whole
|
|
362
367
|
# of it at the creation rate would overstate legacy sessions by more
|
|
363
368
|
# than reading it as reads understates them.
|
|
369
|
+
group_entries = row.pop("_entries")
|
|
370
|
+
row["cache_usage"] = summarize_cache_usage(group_entries)
|
|
371
|
+
row["cache_hit_ratio"] = row["cache_usage"]["cache_hit_ratio"]
|
|
372
|
+
row["run_types"] = {
|
|
373
|
+
kind: {"runs": len(subset), **summarize_cache_usage(subset)}
|
|
374
|
+
for kind, subset in (
|
|
375
|
+
("continued", [e for e in group_entries if e.get("parent_run_id")]),
|
|
376
|
+
("cold", [e for e in group_entries if not e.get("parent_run_id")]),
|
|
377
|
+
)
|
|
378
|
+
}
|
|
364
379
|
split = row["cache_creation_tokens"] + row["cache_read_tokens"]
|
|
365
380
|
legacy_cache = max(0, row["cached_input_tokens"] - split)
|
|
366
381
|
derived = normalize_usage(
|
|
@@ -381,15 +396,34 @@ def _summarize_stats(entries):
|
|
|
381
396
|
reverse=True,
|
|
382
397
|
)
|
|
383
398
|
|
|
399
|
+
def _merge_cache_usage(summaries):
|
|
400
|
+
totals = {}
|
|
401
|
+
for key in ("input_tokens", "cache_creation_tokens", "cache_read_tokens", "output_tokens",
|
|
402
|
+
"measured_runs", "measured_input_tokens", "measured_cache_read_tokens"):
|
|
403
|
+
values = [s[key] for s in summaries if s[key] is not None]
|
|
404
|
+
totals[key] = sum(values) if values else (0 if key.startswith("measured_") else None)
|
|
405
|
+
inputs = totals["measured_input_tokens"]
|
|
406
|
+
totals["cache_hit_ratio"] = totals["measured_cache_read_tokens"] / inputs if inputs else None
|
|
407
|
+
return totals
|
|
408
|
+
|
|
409
|
+
|
|
384
410
|
def _stats_totals(rows):
|
|
411
|
+
cache = _merge_cache_usage([row["cache_usage"] for row in rows])
|
|
385
412
|
return {
|
|
413
|
+
"cache_usage": cache,
|
|
414
|
+
"cache_hit_ratio": cache["cache_hit_ratio"],
|
|
415
|
+
"run_types": {
|
|
416
|
+
kind: {"runs": sum(row["run_types"][kind]["runs"] for row in rows),
|
|
417
|
+
**_merge_cache_usage([row["run_types"][kind] for row in rows])}
|
|
418
|
+
for kind in ("cold", "continued")
|
|
419
|
+
},
|
|
386
420
|
"sessions": len(rows),
|
|
387
421
|
"launches": sum(row["launches"] for row in rows),
|
|
388
422
|
"successes": sum(row["successes"] for row in rows),
|
|
389
423
|
"failures": sum(row["failures"] for row in rows),
|
|
390
424
|
"duration_ms": sum(row["duration_ms"] for row in rows),
|
|
391
425
|
"usage_runs": sum(row["usage_runs"] for row in rows),
|
|
392
|
-
**{key: sum(row[key] for row in rows) for key in
|
|
426
|
+
**{key: sum(row[key] for row in rows) for key in TOKEN_USAGE_KEYS},
|
|
393
427
|
"weighted_tokens": sum(row["weighted_tokens"] for row in rows),
|
|
394
428
|
"weighted_runs": sum(row["weighted_runs"] for row in rows),
|
|
395
429
|
"unweighted_runs": sum(row["unweighted_runs"] for row in rows),
|
|
@@ -448,7 +482,7 @@ def _format_history(entries, use_color=False, active_sessions=None):
|
|
|
448
482
|
active_sessions = active_sessions or set()
|
|
449
483
|
if not entries:
|
|
450
484
|
return "No launch history."
|
|
451
|
-
rows = [[_style(value, "1", use_color) for value in ["SESSION", "PROV.", "RESULT", "DURATION", "WHEN", "TRANSCRIPT"]]]
|
|
485
|
+
rows = [[_style(value, "1", use_color) for value in ["SESSION", "PROV.", "RESULT", "DURATION", "WHEN", "TOKENS", "TRANSCRIPT"]]]
|
|
452
486
|
for entry in entries:
|
|
453
487
|
transcript_path = entry.get("transcript_path")
|
|
454
488
|
session_name = entry.get("session_name") or "-"
|
|
@@ -461,6 +495,8 @@ def _format_history(entries, use_color=False, active_sessions=None):
|
|
|
461
495
|
_style(status, status_color, use_color),
|
|
462
496
|
_style(_format_duration_ms(entry.get("duration_ms")), "33" if entry.get("duration_ms") else "2", use_color),
|
|
463
497
|
_dim(_format_relative_age(entry.get("started_at")), use_color),
|
|
498
|
+
_dim(f"{_format_token_count((entry.get('usage') or {}).get('total_tokens'))} "
|
|
499
|
+
f"{format_cache_ratio((entry.get('usage') or {}).get('cache_hit_ratio'))}", use_color),
|
|
464
500
|
_dim(os.path.basename(transcript_path) if transcript_path else "-", use_color),
|
|
465
501
|
])
|
|
466
502
|
return "\n".join([
|
|
@@ -529,10 +565,12 @@ def _format_stats(rows, totals, period=None, use_color=False, active_sessions=No
|
|
|
529
565
|
_style(str(row["launches"]), "1", use_color),
|
|
530
566
|
_style(str(row["usage_runs"]), "32" if row["usage_runs"] else "2", use_color),
|
|
531
567
|
_style(_format_token_count(row["input_tokens"]), "96" if row["input_tokens"] else "2", use_color),
|
|
532
|
-
_style(_format_token_count(row["cached_input_tokens"])
|
|
568
|
+
_style(_format_token_count(row["cached_input_tokens"]) if row["cache_usage"]["cache_read_tokens"] is not None
|
|
569
|
+
or row["cache_usage"]["cache_creation_tokens"] is not None else "n/d",
|
|
570
|
+
"96" if row["cached_input_tokens"] else "2", use_color),
|
|
533
571
|
_style(_format_token_count(row["output_tokens"]), "96" if row["output_tokens"] else "2", use_color),
|
|
534
572
|
_style(_format_token_count(row["reasoning_tokens"]), "95" if row["reasoning_tokens"] else "2", use_color),
|
|
535
|
-
_style(_format_token_count(row[
|
|
573
|
+
_style(f"{_format_token_count(row['total_tokens'])} {format_cache_ratio(row.get('cache_hit_ratio'))}", "96" if row["total_tokens"] else "2", use_color),
|
|
536
574
|
_style(_format_token_count(row["weighted_tokens"]) if row["weighted_runs"] else "-",
|
|
537
575
|
"1;95" if row["weighted_runs"] else "2", use_color),
|
|
538
576
|
_style(_format_usd(row["cost_usd"]) if row["priced_runs"] else "-",
|
|
@@ -550,7 +588,7 @@ def _format_stats(rows, totals, period=None, use_color=False, active_sessions=No
|
|
|
550
588
|
_dim(
|
|
551
589
|
"Totals: "
|
|
552
590
|
f"{totals['launches']} runs, {totals['usage_runs']} with usage, "
|
|
553
|
-
f"{_format_token_count(totals['total_tokens'])} tokens "
|
|
591
|
+
f"{_format_token_count(totals['total_tokens'])} tokens {format_cache_ratio(totals.get('cache_hit_ratio'))} "
|
|
554
592
|
f"({_format_token_count(totals['weighted_tokens']) if totals['weighted_runs'] else '-'} cost-equivalent"
|
|
555
593
|
+ (f" on {totals['weighted_runs']}/{totals['usage_runs']} measured runs"
|
|
556
594
|
if totals["usage_runs"] else "")
|
|
@@ -633,6 +671,9 @@ def handle_history(rest, ctx):
|
|
|
633
671
|
limit = 0 if parsed["summary"] or has_period else parsed["limit"]
|
|
634
672
|
entries = ctx["service"]["get_launch_history"](parsed["name"], limit=limit)
|
|
635
673
|
entries = _filter_history_period(entries, parsed["period"])
|
|
674
|
+
entries = [{**entry, "parent_run_id": entry.get("parent_run_id"),
|
|
675
|
+
"usage": {**entry["usage"], "cache_hit_ratio": cache_hit_ratio(entry["usage"], provider=entry.get("provider")) if is_vouched(entry["usage"]) else None}
|
|
676
|
+
if isinstance(entry.get("usage"), dict) else entry.get("usage")} for entry in entries]
|
|
636
677
|
if has_period and not parsed["summary"]:
|
|
637
678
|
entries = entries[:parsed["limit"]]
|
|
638
679
|
message = (
|
package/src/interactive_usage.py
CHANGED
|
@@ -261,9 +261,13 @@ def _codex_usage(handle):
|
|
|
261
261
|
# larger than the input count is not a subset, so that record is
|
|
262
262
|
# left alone rather than forced to zero.
|
|
263
263
|
reported_input = _number(usage.get("input_tokens"))
|
|
264
|
-
cache_read = _number(usage.get("cached_input_tokens")
|
|
264
|
+
cache_read = _number(usage["cached_input_tokens"]) if usage.get("cached_input_tokens") is not None else None
|
|
265
|
+
cache_write = _number(usage["cache_write_input_tokens"]) if usage.get("cache_write_input_tokens") is not None else None
|
|
266
|
+
cached = (cache_read or 0) + (cache_write or 0)
|
|
265
267
|
latest = normalize_usage(
|
|
266
|
-
|
|
268
|
+
provider="codex",
|
|
269
|
+
input_tokens=reported_input - cached if cached <= reported_input else reported_input,
|
|
270
|
+
cache_creation_tokens=cache_write,
|
|
267
271
|
cache_read_tokens=cache_read,
|
|
268
272
|
output_tokens=_number(usage.get("output_tokens")),
|
|
269
273
|
reasoning_tokens=_number(usage.get("reasoning_output_tokens")) or None,
|
|
@@ -286,6 +290,7 @@ def _claude_usage(handle):
|
|
|
286
290
|
inside `output_tokens` -- so that field stays absent by design.
|
|
287
291
|
"""
|
|
288
292
|
totals = {"input_tokens": 0, "cache_creation_tokens": 0, "cache_read_tokens": 0, "output_tokens": 0}
|
|
293
|
+
missing = set(totals)
|
|
289
294
|
long_ttl = None
|
|
290
295
|
seen = set()
|
|
291
296
|
model = None
|
|
@@ -301,6 +306,11 @@ def _claude_usage(handle):
|
|
|
301
306
|
if identifier in seen:
|
|
302
307
|
continue
|
|
303
308
|
seen.add(identifier)
|
|
309
|
+
for source, target in (("input_tokens", "input_tokens"), ("output_tokens", "output_tokens"),
|
|
310
|
+
("cache_creation_input_tokens", "cache_creation_tokens"),
|
|
311
|
+
("cache_read_input_tokens", "cache_read_tokens")):
|
|
312
|
+
if usage.get(source) is not None:
|
|
313
|
+
missing.discard(target)
|
|
304
314
|
totals["input_tokens"] += _number(usage.get("input_tokens"))
|
|
305
315
|
totals["cache_creation_tokens"] += _number(usage.get("cache_creation_input_tokens"))
|
|
306
316
|
totals["cache_read_tokens"] += _number(usage.get("cache_read_input_tokens"))
|
|
@@ -316,7 +326,8 @@ def _claude_usage(handle):
|
|
|
316
326
|
continue
|
|
317
327
|
if not seen and not any(totals.values()):
|
|
318
328
|
return None, model
|
|
319
|
-
return normalize_usage(**
|
|
329
|
+
return normalize_usage(**{k: None if k in missing else v for k, v in totals.items()},
|
|
330
|
+
cache_creation_1h_tokens=long_ttl), model
|
|
320
331
|
|
|
321
332
|
|
|
322
333
|
def _number(value):
|
|
@@ -326,7 +337,7 @@ def _number(value):
|
|
|
326
337
|
return 0
|
|
327
338
|
|
|
328
339
|
|
|
329
|
-
#: The quantities a provider actually reports. The other
|
|
340
|
+
#: The quantities a provider actually reports. The other fields of a usage
|
|
330
341
|
#: record are derived by `normalize_usage` and must never be differenced.
|
|
331
342
|
MEASURED_KEYS = (
|
|
332
343
|
"input_tokens",
|
|
@@ -338,7 +349,7 @@ MEASURED_KEYS = (
|
|
|
338
349
|
)
|
|
339
350
|
|
|
340
351
|
|
|
341
|
-
def usage_delta(cumulative, previous):
|
|
352
|
+
def usage_delta(cumulative, previous, provider=None):
|
|
342
353
|
"""What this run added to a transcript that already held `previous`.
|
|
343
354
|
|
|
344
355
|
Both readers return a *cumulative* figure: Claude's is the sum over the
|
|
@@ -372,7 +383,7 @@ def usage_delta(cumulative, previous):
|
|
|
372
383
|
if difference < 0:
|
|
373
384
|
return None
|
|
374
385
|
parts[key] = difference
|
|
375
|
-
return normalize_usage(**parts)
|
|
386
|
+
return normalize_usage(**parts, provider=provider)
|
|
376
387
|
|
|
377
388
|
|
|
378
389
|
def transcript_predates_run(path, started_at):
|
|
@@ -224,6 +224,7 @@ def read_transcript_outcome(path):
|
|
|
224
224
|
if not path:
|
|
225
225
|
return outcome
|
|
226
226
|
totals = {"input_tokens": 0, "cache_creation_tokens": 0, "cache_read_tokens": 0, "output_tokens": 0}
|
|
227
|
+
missing = set(totals)
|
|
227
228
|
long_ttl = None
|
|
228
229
|
# This path had no de-duplication at all, so a transcript that records the
|
|
229
230
|
# same billed response more than once inflated the total outright.
|
|
@@ -266,6 +267,11 @@ def read_transcript_outcome(path):
|
|
|
266
267
|
seen = True
|
|
267
268
|
if isinstance(message.get("model"), str) and message["model"]:
|
|
268
269
|
models.add(message["model"])
|
|
270
|
+
for source, target in (("input_tokens", "input_tokens"), ("output_tokens", "output_tokens"),
|
|
271
|
+
("cache_creation_input_tokens", "cache_creation_tokens"),
|
|
272
|
+
("cache_read_input_tokens", "cache_read_tokens")):
|
|
273
|
+
if usage.get(source) is not None:
|
|
274
|
+
missing.discard(target)
|
|
269
275
|
totals["input_tokens"] += usage.get("input_tokens") or 0
|
|
270
276
|
totals["cache_creation_tokens"] += usage.get("cache_creation_input_tokens") or 0
|
|
271
277
|
totals["cache_read_tokens"] += usage.get("cache_read_input_tokens") or 0
|
|
@@ -276,7 +282,8 @@ def read_transcript_outcome(path):
|
|
|
276
282
|
except OSError:
|
|
277
283
|
return outcome
|
|
278
284
|
if seen:
|
|
279
|
-
outcome["usage"] = normalize_usage(**
|
|
285
|
+
outcome["usage"] = normalize_usage(**{k: None if k in missing else v for k, v in totals.items()},
|
|
286
|
+
cache_creation_1h_tokens=long_ttl)
|
|
280
287
|
if len(models) == 1:
|
|
281
288
|
outcome["usage_model"] = next(iter(models))
|
|
282
289
|
return outcome
|
package/src/provider_runtime.py
CHANGED
|
@@ -1006,7 +1006,7 @@ def _build_headless_launch_spec(session, cwd=None, env_override=None, initial_pr
|
|
|
1006
1006
|
args = ["--print", "--output-format", "json"]
|
|
1007
1007
|
conversation_id = _conversation_id(session)
|
|
1008
1008
|
if conversation_id:
|
|
1009
|
-
args += ["--session-id", conversation_id]
|
|
1009
|
+
args += ["--resume" if session.get("continue_run") else "--session-id", conversation_id]
|
|
1010
1010
|
if model:
|
|
1011
1011
|
args += ["--model", _claude_cli_model(model)]
|
|
1012
1012
|
# --max-budget-usd and --fallback-model are --print-only in the claude
|
|
@@ -1045,6 +1045,8 @@ def _build_headless_launch_spec(session, cwd=None, env_override=None, initial_pr
|
|
|
1045
1045
|
if permission:
|
|
1046
1046
|
args += HEADLESS_CODEX_PERMISSION_ARGS.get(permission, [])
|
|
1047
1047
|
args += preference_args
|
|
1048
|
+
if session.get("continue_run"):
|
|
1049
|
+
args += ["resume", _conversation_id(session)]
|
|
1048
1050
|
if initial_prompt:
|
|
1049
1051
|
# `--` keeps a prompt that starts with `-` from being read as an option.
|
|
1050
1052
|
args += ["--", initial_prompt]
|
package/src/run_command.py
CHANGED
|
@@ -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,6 +169,8 @@ 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,
|
|
165
175
|
"session": session.get("name") if session else None,
|
|
166
176
|
"provider": session.get("provider") if session else parsed.get("provider"),
|
|
@@ -208,6 +218,8 @@ def run_result_payload(api_schema_version, ok, parsed, session, run_info=None,
|
|
|
208
218
|
"ok": bool(ok),
|
|
209
219
|
"action": "run",
|
|
210
220
|
"launcher": "cdx",
|
|
221
|
+
"parent_run_id": parsed.get("continue_run"),
|
|
222
|
+
"conversation_id": run_conversation_id(session, run_info),
|
|
211
223
|
"session": session.get("name") if session else None,
|
|
212
224
|
"provider": session.get("provider") if session else parsed.get("provider"),
|
|
213
225
|
"model": run_info.get("effective_model", parsed.get("model") or ((session.get("launch") or {}).get("model") if session 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):
|