cdx-manager 0.19.3 → 0.20.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 +46 -4
- package/VERSION +1 -1
- package/changelogs/CHANGELOGS_0_20_0.md +71 -0
- package/checksums/release-archives.json +11 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/commands/launch.py +65 -11
- package/src/commands/status.py +128 -11
- package/src/health.py +10 -1
- package/src/interactive_usage.py +294 -35
- package/src/provider_background.py +19 -10
- package/src/provider_runtime.py +67 -9
- package/src/repair.py +10 -0
- package/src/run_registry.py +5 -1
- package/src/run_usage.py +378 -30
- package/src/session_service.py +11 -2
- package/src/session_store.py +54 -0
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<br clear="left"/>
|
|
6
6
|
|
|
7
|
-
[](LICENSE) ](LICENSE)  
|
|
8
8
|
|
|
9
9
|
**Stop guessing which AI account still has quota.** `cdx` tracks the rate-limit window of every Codex and Claude account you own, tells you which one is usable right now, and launches it with isolated auth — in one command.
|
|
10
10
|
|
|
@@ -86,7 +86,7 @@ The reason `cdx` exists: knowing, across every account you own, which one you ca
|
|
|
86
86
|
- **Multiple providers, one tool.** Register as many Codex, Claude, Antigravity, or Ollama sessions as you need. Codex and Claude get isolated auth environments; Antigravity is launchable through `agy` with OS-keyring auth; Ollama runs local models through `ollama run`.
|
|
87
87
|
- **Instant launch.** `cdx work` opens your "work" session. `cdx personal` opens another. No config files to edit mid-flow.
|
|
88
88
|
- **Quick relaunch.** `cdx last` reopens the most recently launched assistant profile.
|
|
89
|
-
- **Resume by identity, not by recency.** Each session records the provider's own conversation id — imposed at launch for Claude, read back from the rollout for Codex — so `cdx resume <name>` reopens *that* conversation regardless of your working directory or of which session ran last. `cdx can-resume --json` reports the strategy and where the id came from, so an automated caller can tell a named resume from a best-effort one.
|
|
89
|
+
- **Resume by identity, not by recency.** Each session records the provider's own conversation id — imposed at launch for Claude, read back from the rollout for Codex — so `cdx resume <name>` reopens *that* conversation regardless of your working directory or of which session ran last. The conversation is looked up before it is named: a recorded id whose transcript the provider never wrote degrades to the provider's own fallback (`--continue`, or `resume --last`) and reports `reason: conversation_not_found`, rather than naming a conversation that does not exist and letting the provider exit with an error. `cdx can-resume --json` reports the strategy and where the id came from, so an automated caller can tell a named resume from a best-effort one.
|
|
90
90
|
- **Auth guardrails.** `cdx` checks authentication before launching. If a session is not logged in, it tells you exactly what to run — no silent failures.
|
|
91
91
|
- **Persistent launch settings.** Pin per-session power, permission, and fast-mode preferences once; `cdx` reapplies them on every launch until you unset them.
|
|
92
92
|
- **Spend ceiling and model fallback.** `cdx set <name> --budget 5` caps what a headless run may spend, and `--fallback-model sonnet,haiku` lets a run continue on the next model when the first is overloaded. Both are Claude Code flags that the provider accepts with `--print` only, so they apply to `cdx run` and not to interactive launches; `cdx configs` says so.
|
|
@@ -537,7 +537,7 @@ cdx history --summary --from 2026-05-01 --to 2026-05-28
|
|
|
537
537
|
| `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 |
|
|
538
538
|
| `cdx runs [--limit N] [--since 7d\|today\|DATE] --json` | List recent runs; `--since` returns every run completed after the cursor and ignores `--limit` |
|
|
539
539
|
| `cdx schema --json` | Publish the enums, mutually-exclusive argument groups, and error codes programmatic callers should validate against |
|
|
540
|
-
| `cdx stats [name] [--since 7d\|today\|DATE] [--from DATE] [--to DATE] [--json]` | Aggregate launch counts, duration, and known
|
|
540
|
+
| `cdx stats [name] [--since 7d\|today\|DATE] [--from DATE] [--to DATE] [--json]` | Aggregate launch counts, duration, and known token usage by session, for headless and interactive runs alike |
|
|
541
541
|
| `cdx status [--json] [--refresh\|--cached] [--timeout SECONDS]` | Show token usage table for all sessions; empty credits, block, and reset columns are omitted; `--cached` skips live provider probes and returns only stored status |
|
|
542
542
|
| `cdx status --small [--refresh\|--cached] [--timeout SECONDS]` / `cdx status -s [--refresh\|--cached] [--timeout SECONDS]` | Show compact token usage table without provider, blocking quota, credits, and updated columns |
|
|
543
543
|
| `cdx status <name> [--json] [--refresh\|--cached] [--timeout SECONDS]` | Show detailed usage breakdown for one session; `--cached` avoids live provider refreshes |
|
|
@@ -708,7 +708,49 @@ cdx run \
|
|
|
708
708
|
|
|
709
709
|
The result includes `launcher: "cdx"`, `run_id`, selected `session`, `provider`, `exit_code`, `duration_seconds`, absolute `transcript_path`, `stdout_path`, `stderr_path`, and normalized usage token fields. Codex headless runs use `codex exec --json`; Claude headless runs use `claude --print --output-format json`. Token counts are `null` when the provider does not expose a supported JSON or JSONL usage shape.
|
|
710
710
|
|
|
711
|
-
Known headless 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
|
|
711
|
+
Known headless and completed interactive token usage is persisted in launch history and can be aggregated later. Interactive records are best-effort local data: Codex reads its latest cumulative rollout snapshot, 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.
|
|
712
|
+
|
|
713
|
+
Every usage record, whichever path produced it, uses one set of definitions:
|
|
714
|
+
|
|
715
|
+
| Field | Meaning |
|
|
716
|
+
| --- | --- |
|
|
717
|
+
| `input_tokens` | Uncached input **only**. Codex reports a cache-inclusive count natively; cdx reduces it to the remainder so the field means the same thing on every provider. |
|
|
718
|
+
| `cache_creation_tokens` | Tokens written to the provider's prompt cache. |
|
|
719
|
+
| `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. |
|
|
720
|
+
| `cached_input_tokens` | `cache_creation_tokens + cache_read_tokens`, derived. The single CACHE column in `cdx stats` shows this. |
|
|
721
|
+
| `output_tokens` | Generated tokens. |
|
|
722
|
+
| `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. |
|
|
723
|
+
| `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. |
|
|
724
|
+
|
|
725
|
+
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.
|
|
726
|
+
|
|
727
|
+
`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 0.1x, cache write 1.25x, output 5x — and reports the result in uncached-input-equivalent tokens. These are ratios rather than prices: across the current model lineup output is 5x input on every model and the cache multipliers do not vary by model either, so the figure needs no per-model price table and does not go stale when prices change. It is **not** a currency amount, and it is not comparable across providers whose own prices differ. The cache-write multiplier assumes the five-minute cache TTL; a one-hour TTL costs 2x and nothing in a transcript says which was used, so long-TTL writes are slightly under-weighted.
|
|
728
|
+
|
|
729
|
+
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.
|
|
730
|
+
|
|
731
|
+
The **`USD~`** column turns that into money for runs whose serving model cdx recorded. It needs one number per model — the list price per million uncached input tokens — because `COST~` has already absorbed every other rate. Prices live in configuration, not in code: set `CDX_TOKEN_PRICES` to a JSON object mapping model id to dollars per million tokens to override the built-in table, whose last review date is printed with the totals. A run whose model cdx never saw stays unpriced rather than being charged at a default tier, and the totals line says how many runs were actually priced.
|
|
732
|
+
|
|
733
|
+
The table carries two numbers per model — uncached input and output per million tokens — and derives the cache rates from the input rate: a cache read is 0.1x it and a cache write 1.25x, a relationship that holds on both vendors. Each token class is then priced at its own rate rather than through the weighted figure, because the weighted shortcut is only correct while every model shares one output ratio, and they do not: Anthropic bills output at 5x input, OpenAI at 6x.
|
|
734
|
+
|
|
735
|
+
Three caveats stated rather than hidden. A run is priced at the model serving its most recent record, so a run that switched models mid-way is priced at the newer one. The figure is an estimate at public list prices — not an invoice. And OpenAI's long-context tier, which roughly doubles both rates above a 272K-token request, is **not** modelled: cdx records tokens per run rather than per request, so it cannot tell which requests crossed that line, and long-context Codex work is under-costed.
|
|
736
|
+
|
|
737
|
+
A model absent from the table stays unpriced rather than being charged a default tier, and the totals line names it — which is exactly the key `CDX_TOKEN_PRICES` wants:
|
|
738
|
+
|
|
739
|
+
```
|
|
740
|
+
Totals: 26 runs, ... ; no price for gpt-6-unreleased)
|
|
741
|
+
CDX_TOKEN_PRICES='{"gpt-6-unreleased": {"input": 3, "output": 18}}' cdx stats
|
|
742
|
+
```
|
|
743
|
+
|
|
744
|
+
**Prices are the only part of this that rots on its own.** A test fails once the table has gone 90 days without review, and `scripts/check_token_prices.py` compares it against published pricing — outside the test suite, since a check that needs the network fails for reasons nobody wants in CI. A row it cannot confirm is reported as unverified, never as passing. When it finds a difference, follow [`docs/token-prices-runbook.md`](docs/token-prices-runbook.md) — which also records where each vendor's numbers actually live, and which pages refuse to be read by a script.
|
|
745
|
+
|
|
746
|
+
Runs recorded before these definitions existed carry figures that are fictitious rather than imprecise — one real session reported 206.6M cached tokens for a period in which its own conversation consumed 33,608, because the old reader picked whichever transcript had the newest modification time and re-read all of it on every launch. They cannot be recomputed: a run's true usage is the increment its transcript gained while it ran, and nothing recorded that. They are therefore excluded from every total automatically, and the totals line says how many runs that covered — so a period containing only such runs reads as absent rather than as a session that spent nothing. The records themselves are left alone, which keeps them recoverable if the marker ever proves wrong; `cdx repair` clears them from the file for anyone who would rather they were gone:
|
|
747
|
+
|
|
748
|
+
```
|
|
749
|
+
cdx repair --dry-run # says how many records carry unvouched usage
|
|
750
|
+
cdx repair # drops the figures; runs, durations and timestamps stay
|
|
751
|
+
```
|
|
752
|
+
|
|
753
|
+
Records that *are* vouched for but predate the split — headless runs, and anything recorded between the definition landing and the model being captured — are shown as faithfully as their data allows: they carry no creation/read split and no model, so their cached tokens are read as cache reads for weighting and they stay unpriced. `cdx stats` derives each row's `TOTAL` from the columns it displays, so a row always adds up even while history spans both eras.
|
|
712
754
|
|
|
713
755
|
```bash
|
|
714
756
|
cdx stats --since 7d --json
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.20.0
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# CDX Manager 0.20.0
|
|
2
|
+
|
|
3
|
+
## Highlights
|
|
4
|
+
|
|
5
|
+
- `cdx stats` reported token figures that were wrong by up to four orders of magnitude. Every number in that table has been rebuilt on one definition, verified against an independent reader of the same transcripts.
|
|
6
|
+
- Sessions are ranked by what they cost rather than by how many tokens they moved, and priced in currency where the serving model is known.
|
|
7
|
+
- Ollama sessions report real token usage instead of a permanent zero.
|
|
8
|
+
- `cdx resume` works again for Claude, and stops promising resumes that would fail.
|
|
9
|
+
|
|
10
|
+
## Changes
|
|
11
|
+
|
|
12
|
+
### One definition for token usage
|
|
13
|
+
|
|
14
|
+
Three code paths read provider usage — headless stdout, an interactive transcript, and a native background close-out — and each computed the fields its own way. They disagreed in every direction at once: one dropped cache from the total, one counted it twice, one omitted the cached field entirely. All three wrote into the same launch history and the same column, so the table mixed definitions without saying so.
|
|
15
|
+
|
|
16
|
+
`src/run_usage.py` now owns the definition and all three normalize to it. `input_tokens` is the uncached remainder on every provider; cache creation and cache read are separate fields because they cost 1.25x and 0.1x of uncached input and no summed figure can recover that split; the total covers all four token classes. The run registry normalizes on the way in, so `cdx run --json`, `cdx run-status` and `cdx run-report` publish the same shape.
|
|
17
|
+
|
|
18
|
+
An independent reader of the same transcripts now agrees with cdx exactly, field by field.
|
|
19
|
+
|
|
20
|
+
### Each run is billed for its own tokens
|
|
21
|
+
|
|
22
|
+
Both interactive readers report a cumulative figure and every run stored it, so a resumed session was re-billed for its entire history on each resume — one observed session's cache read went from 2.6M to 7.6M on a single additional launch, and `cdx stats` summed that inflation again.
|
|
23
|
+
|
|
24
|
+
A run now stores the increment over the previous run on the same transcript. A shrunk, rotated or replaced transcript reports absence rather than arithmetic, and the first run against a transcript that predates it leaves a baseline without claiming history it never measured.
|
|
25
|
+
|
|
26
|
+
### Each billed response is counted once
|
|
27
|
+
|
|
28
|
+
A Claude transcript records the same billed API response under several record ids. Counting per record inflated one real transcript by 1.55x — 2741 rows for 1765 distinct responses. De-duplication now keys on the API response itself.
|
|
29
|
+
|
|
30
|
+
### The session's own transcript
|
|
31
|
+
|
|
32
|
+
Usage was read from whichever transcript in the provider home had the newest modification time, so a run could be billed against an unrelated project's file — sessions reported three runs and eighteen output tokens. Resolution is now by the conversation id cdx already holds, and a scan that cannot conclude reports absence instead of whichever candidate it had reached.
|
|
33
|
+
|
|
34
|
+
### Ranked by cost, and priced
|
|
35
|
+
|
|
36
|
+
A raw token total ranks a cache read equal to an output token worth fifty times more, and cache reads are typically the overwhelming majority of tokens — so the old ranking was very nearly a measure of cache reads alone.
|
|
37
|
+
|
|
38
|
+
`cdx stats` gains a `COST~` column and sorts on it, weighting each token class by its cost relative to an uncached input token. A `USD~` column prices runs whose serving model cdx recorded, from a table carrying two numbers per model. A model absent from that table stays unpriced rather than charged a default tier, and the totals line names it.
|
|
39
|
+
|
|
40
|
+
`CDX_TOKEN_PRICES` overrides the table without a release. `scripts/check_token_prices.py` compares it against published pricing, a test fails once it has gone ninety days unreviewed, and `docs/token-prices-runbook.md` records where each vendor's numbers actually live.
|
|
41
|
+
|
|
42
|
+
### Ollama token usage
|
|
43
|
+
|
|
44
|
+
Ollama writes no transcript of its own, so it is now launched with `--verbose` and its per-response counts are parsed from the terminal capture cdx already takes — after the same normalization the handoff reader applies, because a real capture carries cursor sequences between individual words.
|
|
45
|
+
|
|
46
|
+
The provider-flag health check now covers flags cdx passes on its own initiative, not only permission mappings.
|
|
47
|
+
|
|
48
|
+
### Resume
|
|
49
|
+
|
|
50
|
+
`cdx <session> --resume` minted a fresh conversation id and then asked the provider to resume it, so every Claude resume failed with `No conversation found with session ID`. A resume now carries the conversation it is resuming.
|
|
51
|
+
|
|
52
|
+
Resumability is a checked fact rather than a stored string: a recorded conversation the provider never wrote degrades to `--continue` or `resume --last` and reports `conversation_not_found`, instead of being named and failing. What `cdx can-resume --json` reports and what `cdx resume` runs now read the same decision.
|
|
53
|
+
|
|
54
|
+
### Dropping figures cdx cannot vouch for
|
|
55
|
+
|
|
56
|
+
Launch history written before these definitions is fictitious rather than imprecise, and cannot be recomputed: a run's true usage is the increment its transcript gained while it ran, and nothing recorded that. They are excluded from every total automatically from this release, with the totals line naming how many runs that covered — no command to remember, and no upgrade that leaves the fiction on screen. The stored records are left intact so they stay recoverable; `cdx repair` clears them from the file for anyone who wants that.
|
|
57
|
+
|
|
58
|
+
## Upgrading
|
|
59
|
+
|
|
60
|
+
Usage records gain `cache_creation_tokens` and `cache_read_tokens`; existing fields keep their names and `cached_input_tokens` remains their sum. `cdx stats --json` gains `weighted_tokens`, `cost_usd`, `priced_runs`, `unpriced_models` and `unvouched_runs`. Nothing was removed.
|
|
61
|
+
|
|
62
|
+
`cdx can-resume --json` can now report `reason: conversation_not_found`. `resumable` stays `true` in every case that used to return it; only the strategy changes.
|
|
63
|
+
|
|
64
|
+
Token totals will drop, in some cases sharply — one real session fell from 206.6M to 361.3K. That is the fiction leaving, not data loss: the totals line reports how many runs were excluded, and `cdx repair --dry-run` says how many stored records carry such usage.
|
|
65
|
+
|
|
66
|
+
## Known limits
|
|
67
|
+
|
|
68
|
+
- Historical figures are excluded rather than corrected; recomputation is not possible, so those runs report no usage at all.
|
|
69
|
+
- OpenAI's long-context tier, which roughly doubles both rates above a 272K-token request, is not modelled: cdx records tokens per run rather than per request, so long-context Codex work is under-costed.
|
|
70
|
+
- A run that switched models mid-way is priced at the model serving its most recent record.
|
|
71
|
+
- OpenAI prices were taken from third-party aggregators; `openai.com` refuses automated fetches. This is why the drift check exists.
|
|
@@ -298,6 +298,17 @@
|
|
|
298
298
|
"v0.19.2": {
|
|
299
299
|
"github_tarball_sha256": "d644666c727b2a29fa022700da1c23483173a2110606b994d200f72feb959c3b",
|
|
300
300
|
"github_zip_sha256": "bca42637ddab4502e2dd6d9d63239eca322d953f8d910d8ada209fcfdd2f98d4"
|
|
301
|
+
},
|
|
302
|
+
"v0.19.3": {
|
|
303
|
+
"github_tarball_sha256": "23e264e7c7f87b1da7052fa47c67e54454e4eee0a27d121565db155324845a06",
|
|
304
|
+
"github_zip_sha256": "5b6eb81fc09581ba25584ad98a99141b1e50433ad76c7261ba4696abf601f274",
|
|
305
|
+
"tray_assets": {
|
|
306
|
+
"aarch64-apple-darwin": "bf289a773c32549cd78028b57dcdfd3d60c6436b038899ce160e84e1f88e9a9d",
|
|
307
|
+
"aarch64-unknown-linux-musl": "4e29448f0acae916ce8b57132d5dfd24dd89c28184f21993bc6346a9aa82ec2f",
|
|
308
|
+
"x86_64-apple-darwin": "a3931de7c38beaba4725940c29692c975f0d078c3a1e7d0ed610061cee6cab81",
|
|
309
|
+
"x86_64-pc-windows-msvc": "c5a29140bb9d634f2533084084ca10cb90ca64d34cac1b664998d305b934a7fb",
|
|
310
|
+
"x86_64-unknown-linux-musl": "d1850f3acdede7c2cdcd31b5ca6d5e2372535a32bf75c0e6c8fd0100af7813f2"
|
|
311
|
+
}
|
|
301
312
|
}
|
|
302
313
|
}
|
|
303
314
|
}
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
package/src/commands/launch.py
CHANGED
|
@@ -25,8 +25,8 @@ from ..cli_render import _dim, _info, _success, _warn
|
|
|
25
25
|
from ..config import PROVIDER_CODEX
|
|
26
26
|
from ..context_store import install_context_for_session, write_context
|
|
27
27
|
from ..errors import CdxError
|
|
28
|
-
from ..interactive_usage import extract_interactive_usage
|
|
29
|
-
from ..provider_runtime import _ensure_session_authentication, _get_auth_home, _run_interactive_provider_command
|
|
28
|
+
from ..interactive_usage import extract_interactive_usage, transcript_predates_run, usage_delta
|
|
29
|
+
from ..provider_runtime import _conversation_id, _ensure_session_authentication, _get_auth_home, _run_interactive_provider_command
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
def _parse_launch_args(args):
|
|
@@ -199,7 +199,7 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
|
|
|
199
199
|
f"Provider {session['provider']} does not support native resume through cdx."
|
|
200
200
|
)
|
|
201
201
|
cwd = _launch_directory(session, ctx, directory, json_flag)
|
|
202
|
-
session = ctx["service"]["launch_session"](command)
|
|
202
|
+
session = ctx["service"]["launch_session"](command, resume=resume)
|
|
203
203
|
capability = _resume_capability_for_session(session, ctx) if resume else None
|
|
204
204
|
if not json_flag and not ctx["stdin_is_tty"]:
|
|
205
205
|
ctx["out"](f"Using directory: {cwd}\n")
|
|
@@ -265,7 +265,9 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
|
|
|
265
265
|
)
|
|
266
266
|
except CdxError as error:
|
|
267
267
|
run_info = getattr(error, "run_info", {}) or {}
|
|
268
|
-
_attach_interactive_usage(
|
|
268
|
+
run_info = _attach_interactive_usage(
|
|
269
|
+
session, run_info, ctx["service"]["get_launch_history"](session["name"], limit=50)
|
|
270
|
+
)
|
|
269
271
|
if runtime_run_id:
|
|
270
272
|
ctx["service"]["finish_session_runtime"](
|
|
271
273
|
session["name"],
|
|
@@ -286,7 +288,9 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
|
|
|
286
288
|
"action": "resume" if resume else "launch",
|
|
287
289
|
"cwd": cwd,
|
|
288
290
|
"exit_code": 0,
|
|
289
|
-
**_attach_interactive_usage(
|
|
291
|
+
**_attach_interactive_usage(
|
|
292
|
+
session, run_info, ctx["service"]["get_launch_history"](session["name"], limit=50)
|
|
293
|
+
),
|
|
290
294
|
})
|
|
291
295
|
if json_flag:
|
|
292
296
|
extra = {"session": ctx["service"]["get_session"](session["name"]), "cwd": cwd}
|
|
@@ -296,18 +300,68 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
|
|
|
296
300
|
return 0
|
|
297
301
|
|
|
298
302
|
|
|
299
|
-
def _attach_interactive_usage(session, run_info):
|
|
300
|
-
"""Attach
|
|
303
|
+
def _attach_interactive_usage(session, run_info, history=None):
|
|
304
|
+
"""Attach this run's own token usage without affecting launch outcome.
|
|
305
|
+
|
|
306
|
+
Both interactive readers report a cumulative figure, so what a run *stores*
|
|
307
|
+
has to be the increment over the previous run on the same transcript --
|
|
308
|
+
otherwise a resumed session re-bills its whole history on every resume, and
|
|
309
|
+
`cdx stats` sums that inflation again.
|
|
310
|
+
|
|
311
|
+
The cumulative is kept beside the delta as `usage_cumulative`: it is the
|
|
312
|
+
baseline the next run differences against, and without it a single run that
|
|
313
|
+
recorded nothing would break the chain for every run after it.
|
|
314
|
+
"""
|
|
301
315
|
run_info = dict(run_info or {})
|
|
302
|
-
|
|
303
|
-
|
|
316
|
+
started_at = run_info.get("started_at")
|
|
317
|
+
cumulative, provider_transcript, match, model = extract_interactive_usage(
|
|
318
|
+
session.get("provider"), _get_auth_home(session), started_at, _conversation_id(session),
|
|
319
|
+
# Ollama writes no provider transcript; its only record is the PTY
|
|
320
|
+
# capture this run already took.
|
|
321
|
+
run_info.get("transcript_path"),
|
|
304
322
|
)
|
|
305
|
-
if usage:
|
|
306
|
-
run_info["usage"] = usage
|
|
307
323
|
if provider_transcript:
|
|
308
324
|
run_info["provider_transcript_path"] = provider_transcript
|
|
325
|
+
# How the transcript was found travels with the number. A recency match
|
|
326
|
+
# is a guess that happened to be the newest file; recording it the same
|
|
327
|
+
# way as an id match would let a wrong attribution read as a
|
|
328
|
+
# measurement, which is the failure this replaced.
|
|
329
|
+
run_info["provider_transcript_match"] = match
|
|
330
|
+
if not cumulative:
|
|
331
|
+
return run_info
|
|
332
|
+
|
|
333
|
+
run_info["usage_cumulative"] = cumulative
|
|
334
|
+
if model:
|
|
335
|
+
# Which model actually served the run, read from the transcript rather
|
|
336
|
+
# than inferred from the session's configured launch settings -- where
|
|
337
|
+
# `model` is optional and usually unset, because the session takes the
|
|
338
|
+
# provider's default.
|
|
339
|
+
run_info["usage_model"] = model
|
|
340
|
+
previous = _previous_cumulative(history, provider_transcript)
|
|
341
|
+
if previous is not None:
|
|
342
|
+
delta = usage_delta(cumulative, previous)
|
|
343
|
+
if delta is not None:
|
|
344
|
+
run_info["usage"] = delta
|
|
345
|
+
# A None delta means the transcript shrank, rotated, or was replaced.
|
|
346
|
+
# The baseline above still updates, so the next run recovers.
|
|
347
|
+
elif not transcript_predates_run(provider_transcript, started_at):
|
|
348
|
+
# First sighting of a transcript this run created: all of it is ours.
|
|
349
|
+
run_info["usage"] = cumulative
|
|
309
350
|
return run_info
|
|
310
351
|
|
|
352
|
+
|
|
353
|
+
def _previous_cumulative(history, transcript_path):
|
|
354
|
+
"""The cumulative the last run on this same transcript left behind."""
|
|
355
|
+
if not history or not transcript_path:
|
|
356
|
+
return None
|
|
357
|
+
for entry in history:
|
|
358
|
+
if entry.get("provider_transcript_path") != transcript_path:
|
|
359
|
+
continue
|
|
360
|
+
cumulative = entry.get("usage_cumulative")
|
|
361
|
+
if isinstance(cumulative, dict):
|
|
362
|
+
return cumulative
|
|
363
|
+
return None
|
|
364
|
+
|
|
311
365
|
def handle_handoff(rest, ctx):
|
|
312
366
|
json_flag, args = _parse_json_flag(rest)
|
|
313
367
|
if len(args) not in (1, 2):
|
package/src/commands/status.py
CHANGED
|
@@ -41,6 +41,14 @@ from ..commands.launch import handle_launch
|
|
|
41
41
|
from ..config import PROVIDER_CLAUDE
|
|
42
42
|
from ..errors import CdxError
|
|
43
43
|
from ..provider_runtime import AUTH_PROBE_AUTHENTICATED, AUTH_PROBE_DEGRADED, _probe_provider_auth_status
|
|
44
|
+
from ..run_usage import (
|
|
45
|
+
USAGE_KEYS,
|
|
46
|
+
estimate_cost,
|
|
47
|
+
is_vouched,
|
|
48
|
+
normalize_usage,
|
|
49
|
+
token_prices,
|
|
50
|
+
weighted_usage,
|
|
51
|
+
)
|
|
44
52
|
from ..status_view import _format_status_detail, _format_status_rows, format_priority_instruction, recommend_priority_rows
|
|
45
53
|
|
|
46
54
|
|
|
@@ -174,6 +182,12 @@ def _format_launch_configs(sessions, use_color=False):
|
|
|
174
182
|
lines.append(_dim(_format_launch_settings_hint(), use_color))
|
|
175
183
|
return "\n".join(lines)
|
|
176
184
|
|
|
185
|
+
def _format_usd(amount):
|
|
186
|
+
if not amount:
|
|
187
|
+
return "-"
|
|
188
|
+
return f"${amount:,.2f}" if amount >= 0.01 else "<$0.01"
|
|
189
|
+
|
|
190
|
+
|
|
177
191
|
def _format_duration_ms(value):
|
|
178
192
|
if value is None:
|
|
179
193
|
return "-"
|
|
@@ -252,8 +266,15 @@ def _summarize_stats(entries):
|
|
|
252
266
|
"failures": 0,
|
|
253
267
|
"duration_ms": 0,
|
|
254
268
|
"usage_runs": 0,
|
|
269
|
+
"cost_usd": 0.0,
|
|
270
|
+
"weighted_tokens": 0,
|
|
271
|
+
"priced_runs": 0,
|
|
272
|
+
"unpriced_models": set(),
|
|
273
|
+
"unvouched_runs": 0,
|
|
255
274
|
"input_tokens": 0,
|
|
256
275
|
"cached_input_tokens": 0,
|
|
276
|
+
"cache_creation_tokens": 0,
|
|
277
|
+
"cache_read_tokens": 0,
|
|
257
278
|
"output_tokens": 0,
|
|
258
279
|
"reasoning_tokens": 0,
|
|
259
280
|
"total_tokens": 0,
|
|
@@ -269,21 +290,103 @@ def _summarize_stats(entries):
|
|
|
269
290
|
except (TypeError, ValueError):
|
|
270
291
|
pass
|
|
271
292
|
usage = entry.get("usage") if isinstance(entry.get("usage"), dict) else {}
|
|
293
|
+
if usage and not is_vouched(usage):
|
|
294
|
+
# Written before the definition existed, so the figures are
|
|
295
|
+
# fictitious rather than merely old. Excluded from every total and
|
|
296
|
+
# counted, so the exclusion is visible instead of silent.
|
|
297
|
+
row["unvouched_runs"] += 1
|
|
298
|
+
usage = {}
|
|
272
299
|
parsed_usage = {
|
|
273
300
|
key: _token_value(usage, key)
|
|
274
|
-
for key in
|
|
301
|
+
for key in USAGE_KEYS
|
|
275
302
|
}
|
|
276
303
|
if any(value is not None for value in parsed_usage.values()):
|
|
277
304
|
row["usage_runs"] += 1
|
|
278
305
|
for key, value in parsed_usage.items():
|
|
279
306
|
row[key] += value or 0
|
|
307
|
+
# Priced per run, against the model that served it. A run whose
|
|
308
|
+
# model cdx never saw stays unpriced rather than being charged at a
|
|
309
|
+
# default tier, and `priced_runs` says how much of the figure is
|
|
310
|
+
# actually covered.
|
|
311
|
+
# Weighted per entry, not on the summed row: the output ratio is
|
|
312
|
+
# the one multiplier that differs between vendors, and a session's
|
|
313
|
+
# runs can span models.
|
|
314
|
+
row["weighted_tokens"] += weighted_usage(
|
|
315
|
+
entry.get("usage"), entry.get("usage_model")) or 0
|
|
316
|
+
cost = estimate_cost(entry.get("usage"), entry.get("usage_model"))
|
|
317
|
+
if cost is not None:
|
|
318
|
+
row["cost_usd"] += cost
|
|
319
|
+
row["priced_runs"] += 1
|
|
320
|
+
elif entry.get("usage_model"):
|
|
321
|
+
# Named the model but could not price it. Recording which one
|
|
322
|
+
# turns an unexplained "-" into something actionable: that
|
|
323
|
+
# string is exactly what CDX_TOKEN_PRICES needs a key for.
|
|
324
|
+
row["unpriced_models"].add(entry["usage_model"])
|
|
280
325
|
started = entry.get("started_at")
|
|
281
326
|
if started and (not row["last_started_at"] or started > row["last_started_at"]):
|
|
282
327
|
row["last_started_at"] = started
|
|
283
328
|
row["provider"] = entry.get("provider") or row["provider"]
|
|
329
|
+
for row in rows.values():
|
|
330
|
+
# Derive from the summed parts rather than summing each run's stored
|
|
331
|
+
# total. Records written before the definition was settled carry a
|
|
332
|
+
# total that excluded cache, and adding those to correct ones produced
|
|
333
|
+
# a row that contradicted itself -- CACHE in the hundreds of millions
|
|
334
|
+
# beside a TOTAL in the low millions.
|
|
335
|
+
#
|
|
336
|
+
# Those records also lack the creation/read split, so the fused
|
|
337
|
+
# `cached_input_tokens` is the one field both eras share. Where the
|
|
338
|
+
# split is missing it is read as cache read: that is what the
|
|
339
|
+
# overwhelming majority of cached tokens are, and weighting the whole
|
|
340
|
+
# of it at the creation rate would overstate legacy sessions by more
|
|
341
|
+
# than reading it as reads understates them.
|
|
342
|
+
split = row["cache_creation_tokens"] + row["cache_read_tokens"]
|
|
343
|
+
legacy_cache = max(0, row["cached_input_tokens"] - split)
|
|
344
|
+
derived = normalize_usage(
|
|
345
|
+
input_tokens=row["input_tokens"],
|
|
346
|
+
cache_creation_tokens=row["cache_creation_tokens"],
|
|
347
|
+
cache_read_tokens=row["cache_read_tokens"] + legacy_cache,
|
|
348
|
+
output_tokens=row["output_tokens"],
|
|
349
|
+
)
|
|
350
|
+
row["total_tokens"] = derived["total_tokens"] or 0
|
|
351
|
+
if not row["weighted_tokens"]:
|
|
352
|
+
# Records written before the model was recorded: weigh the row as a
|
|
353
|
+
# whole, at the default ratio, rather than reporting nothing.
|
|
354
|
+
row["weighted_tokens"] = weighted_usage(derived) or 0
|
|
355
|
+
row["unpriced_models"] = sorted(row["unpriced_models"])
|
|
356
|
+
for row in rows.values():
|
|
357
|
+
# Derive from the summed parts rather than summing each run's stored
|
|
358
|
+
# total. Records written before the definition was settled carry a
|
|
359
|
+
# total that excluded cache, and adding those to correct ones produced
|
|
360
|
+
# a row that contradicted itself -- CACHE in the hundreds of millions
|
|
361
|
+
# beside a TOTAL in the low millions.
|
|
362
|
+
#
|
|
363
|
+
# Those records also lack the creation/read split, so the fused
|
|
364
|
+
# `cached_input_tokens` is the one field both eras share. Where the
|
|
365
|
+
# split is missing it is read as cache read: that is what the
|
|
366
|
+
# overwhelming majority of cached tokens are, and weighting the whole
|
|
367
|
+
# of it at the creation rate would overstate legacy sessions by more
|
|
368
|
+
# than reading it as reads understates them.
|
|
369
|
+
split = row["cache_creation_tokens"] + row["cache_read_tokens"]
|
|
370
|
+
legacy_cache = max(0, row["cached_input_tokens"] - split)
|
|
371
|
+
derived = normalize_usage(
|
|
372
|
+
input_tokens=row["input_tokens"],
|
|
373
|
+
cache_creation_tokens=row["cache_creation_tokens"],
|
|
374
|
+
cache_read_tokens=row["cache_read_tokens"] + legacy_cache,
|
|
375
|
+
output_tokens=row["output_tokens"],
|
|
376
|
+
)
|
|
377
|
+
row["total_tokens"] = derived["total_tokens"] or 0
|
|
378
|
+
if not row["weighted_tokens"]:
|
|
379
|
+
# Records written before the model was recorded: weigh the row as a
|
|
380
|
+
# whole, at the default ratio, rather than reporting nothing.
|
|
381
|
+
row["weighted_tokens"] = weighted_usage(derived) or 0
|
|
382
|
+
row["unpriced_models"] = sorted(row["unpriced_models"])
|
|
284
383
|
return sorted(
|
|
285
384
|
rows.values(),
|
|
286
|
-
|
|
385
|
+
# Ranked by what a session cost, not by how many tokens it moved: a
|
|
386
|
+
# cache read is 0.1x an uncached input token and an output token is 5x,
|
|
387
|
+
# so a raw total sorts a cache-heavy session above one that spent far
|
|
388
|
+
# more on generation.
|
|
389
|
+
key=lambda item: (item["weighted_tokens"], item["duration_ms"], item.get("last_started_at") or "", item["session_name"]),
|
|
287
390
|
reverse=True,
|
|
288
391
|
)
|
|
289
392
|
|
|
@@ -295,11 +398,12 @@ def _stats_totals(rows):
|
|
|
295
398
|
"failures": sum(row["failures"] for row in rows),
|
|
296
399
|
"duration_ms": sum(row["duration_ms"] for row in rows),
|
|
297
400
|
"usage_runs": sum(row["usage_runs"] for row in rows),
|
|
298
|
-
|
|
299
|
-
"
|
|
300
|
-
"
|
|
301
|
-
"
|
|
302
|
-
"
|
|
401
|
+
**{key: sum(row[key] for row in rows) for key in USAGE_KEYS},
|
|
402
|
+
"weighted_tokens": sum(row["weighted_tokens"] for row in rows),
|
|
403
|
+
"cost_usd": sum(row["cost_usd"] for row in rows),
|
|
404
|
+
"priced_runs": sum(row["priced_runs"] for row in rows),
|
|
405
|
+
"unpriced_models": sorted({m for row in rows for m in row["unpriced_models"]}),
|
|
406
|
+
"unvouched_runs": sum(row["unvouched_runs"] for row in rows),
|
|
303
407
|
}
|
|
304
408
|
|
|
305
409
|
def _format_history_period(period):
|
|
@@ -391,7 +495,7 @@ def _format_stats(rows, totals, period=None, use_color=False, active_sessions=No
|
|
|
391
495
|
if not rows:
|
|
392
496
|
return "No launch stats for this period." if _has_history_period(period or {}) else "No launch stats."
|
|
393
497
|
table = [[_style(value, "1", use_color) for value in [
|
|
394
|
-
"SESSION", "PROV.", "RUNS", "USAGE", "IN", "CACHE", "OUT", "REASON", "TOTAL", "TIME", "LAST"
|
|
498
|
+
"SESSION", "PROV.", "RUNS", "USAGE", "IN", "CACHE", "OUT", "REASON", "TOTAL", "COST~", "USD~", "TIME", "LAST"
|
|
395
499
|
]]]
|
|
396
500
|
for row in rows:
|
|
397
501
|
session_name = row["session_name"]
|
|
@@ -405,7 +509,10 @@ def _format_stats(rows, totals, period=None, use_color=False, active_sessions=No
|
|
|
405
509
|
_style(_format_token_count(row["cached_input_tokens"]), "96" if row["cached_input_tokens"] else "2", use_color),
|
|
406
510
|
_style(_format_token_count(row["output_tokens"]), "96" if row["output_tokens"] else "2", use_color),
|
|
407
511
|
_style(_format_token_count(row["reasoning_tokens"]), "95" if row["reasoning_tokens"] else "2", use_color),
|
|
408
|
-
_style(_format_token_count(row["total_tokens"]), "
|
|
512
|
+
_style(_format_token_count(row["total_tokens"]), "96" if row["total_tokens"] else "2", use_color),
|
|
513
|
+
_style(_format_token_count(row["weighted_tokens"]), "1;95" if row["weighted_tokens"] else "2", use_color),
|
|
514
|
+
_style(_format_usd(row["cost_usd"]) if row["priced_runs"] else "-",
|
|
515
|
+
"1;33" if row["priced_runs"] else "2", use_color),
|
|
409
516
|
_style(_format_duration_ms(row["duration_ms"]), "33" if row["duration_ms"] else "2", use_color),
|
|
410
517
|
_dim(_format_relative_age(row.get("last_started_at")), use_color),
|
|
411
518
|
])
|
|
@@ -419,8 +526,18 @@ def _format_stats(rows, totals, period=None, use_color=False, active_sessions=No
|
|
|
419
526
|
_dim(
|
|
420
527
|
"Totals: "
|
|
421
528
|
f"{totals['launches']} runs, {totals['usage_runs']} with usage, "
|
|
422
|
-
f"{_format_token_count(totals['total_tokens'])} tokens
|
|
423
|
-
f"{
|
|
529
|
+
f"{_format_token_count(totals['total_tokens'])} tokens "
|
|
530
|
+
f"({_format_token_count(totals['weighted_tokens'])} cost-equivalent"
|
|
531
|
+
+ (f", {_format_usd(totals['cost_usd'])} at list on {totals['priced_runs']} priced run"
|
|
532
|
+
f"{'s' if totals['priced_runs'] != 1 else ''} [{token_prices()[1]}]"
|
|
533
|
+
if totals["priced_runs"] else ", no run priced")
|
|
534
|
+
+ (f"; no price for {', '.join(totals['unpriced_models'])}"
|
|
535
|
+
if totals["unpriced_models"] else "") + "), "
|
|
536
|
+
+ (f"{totals['unvouched_runs']} run"
|
|
537
|
+
f"{'s' if totals['unvouched_runs'] != 1 else ''} excluded "
|
|
538
|
+
f"(recorded before cdx could measure them; cdx repair clears these), "
|
|
539
|
+
if totals["unvouched_runs"] else "")
|
|
540
|
+
+ f"{_format_duration_ms(totals['duration_ms'])}.",
|
|
424
541
|
use_color,
|
|
425
542
|
),
|
|
426
543
|
])
|
package/src/health.py
CHANGED
|
@@ -12,6 +12,7 @@ from .cli_render import _pad_table, _style
|
|
|
12
12
|
from .config import PROVIDER_ANTIGRAVITY, PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_OLLAMA
|
|
13
13
|
from .provider_runtime import (
|
|
14
14
|
HEADLESS_CODEX_PERMISSION_ARGS,
|
|
15
|
+
LAUNCH_FEATURE_ARGS,
|
|
15
16
|
LAUNCH_PERMISSION_ARGS,
|
|
16
17
|
codex_auth_diagnostic,
|
|
17
18
|
)
|
|
@@ -173,12 +174,17 @@ def _extract_version(text):
|
|
|
173
174
|
|
|
174
175
|
|
|
175
176
|
def _mapped_provider_flags(provider):
|
|
176
|
-
"""Every distinct flag cdx would pass to this provider, by
|
|
177
|
+
"""Every distinct flag cdx would pass to this provider, by reason.
|
|
177
178
|
|
|
178
179
|
Read off the same mappings the launch specs use, so a permission added
|
|
179
180
|
there is automatically covered here rather than quietly unverified.
|
|
180
181
|
Values (a sandbox name, a config expression) are not flags and are not
|
|
181
182
|
checked — only the options themselves.
|
|
183
|
+
|
|
184
|
+
Covers flags cdx passes on its own initiative as well as permission
|
|
185
|
+
mappings. Issue #8 was a mapped flag the CLI never had; a flag cdx invents
|
|
186
|
+
for a feature of its own is exactly as capable of not existing, and used to
|
|
187
|
+
pass through here unverified because it belonged to no permission.
|
|
182
188
|
"""
|
|
183
189
|
mappings = {}
|
|
184
190
|
interactive = LAUNCH_PERMISSION_ARGS.get(provider)
|
|
@@ -186,6 +192,9 @@ def _mapped_provider_flags(provider):
|
|
|
186
192
|
# No entry at all means nobody declared this provider's mapping, which
|
|
187
193
|
# is different from declaring that it has none.
|
|
188
194
|
return {}
|
|
195
|
+
for flag in LAUNCH_FEATURE_ARGS.get(provider, []):
|
|
196
|
+
if flag.startswith("-"):
|
|
197
|
+
mappings.setdefault("cdx feature flags", set()).add(flag)
|
|
189
198
|
for permission, args in interactive.items():
|
|
190
199
|
mappings.setdefault(permission, set()).update(
|
|
191
200
|
arg for arg in args if arg.startswith("-")
|