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/src/run_usage.py
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
|
+
"""What a cdx usage record means, and the only place that decides it.
|
|
2
|
+
|
|
3
|
+
Three code paths read provider usage -- this module for headless stdout,
|
|
4
|
+
`interactive_usage` for a terminal launch, `provider_background` for a native
|
|
5
|
+
background close-out -- and each used to compute the fields its own way. They
|
|
6
|
+
disagreed in every direction at once: one dropped cache from the total, one
|
|
7
|
+
counted it twice, one omitted the cached field entirely. All three wrote into
|
|
8
|
+
the same launch history and the same `cdx stats` column, so the table mixed
|
|
9
|
+
definitions without saying so.
|
|
10
|
+
|
|
11
|
+
The definitions below are therefore normative, not a suggestion:
|
|
12
|
+
|
|
13
|
+
input_tokens Uncached input only. Claude reports it this way
|
|
14
|
+
natively; Codex does not, so its cache-inclusive count
|
|
15
|
+
is reduced here rather than left for a reader to guess.
|
|
16
|
+
cache_creation_tokens Tokens written to the provider's prompt cache.
|
|
17
|
+
cache_read_tokens Tokens served from it.
|
|
18
|
+
cached_input_tokens cache_creation + cache_read. Kept because it is a
|
|
19
|
+
published field of `cdx run --json`, and because the
|
|
20
|
+
stats table shows one CACHE column. It is a derived
|
|
21
|
+
sum, never a source.
|
|
22
|
+
output_tokens Generated tokens.
|
|
23
|
+
reasoning_tokens Reasoning counted separately by the provider. Codex
|
|
24
|
+
reports one; Claude does not and never will -- it bills
|
|
25
|
+
thinking as output, so those tokens are already inside
|
|
26
|
+
output_tokens. An empty REASON column for Claude is
|
|
27
|
+
correct, not a gap to fill.
|
|
28
|
+
total_tokens input + cache_creation + cache_read + output. Cache is
|
|
29
|
+
the bulk of real consumption, so a total that excludes
|
|
30
|
+
it is not a slightly-wrong total, it is a different
|
|
31
|
+
quantity.
|
|
32
|
+
|
|
33
|
+
Cache creation and cache read are separate fields because they cost very
|
|
34
|
+
differently -- 1.25x and 0.1x of uncached input -- and a weighted figure cannot
|
|
35
|
+
recover the split once they are summed.
|
|
36
|
+
|
|
37
|
+
Absence stays absent: `None` means "not reported", which is not zero. Only
|
|
38
|
+
`normalize_usage` may derive `cached_input_tokens` and `total_tokens`, so no
|
|
39
|
+
caller can invent a fourth arithmetic.
|
|
40
|
+
"""
|
|
41
|
+
|
|
1
42
|
import json
|
|
2
43
|
|
|
3
|
-
|
|
44
|
+
#: Order matters only for readability; every consumer addresses these by name.
|
|
45
|
+
USAGE_KEYS = (
|
|
46
|
+
"input_tokens",
|
|
47
|
+
"cached_input_tokens",
|
|
48
|
+
"cache_creation_tokens",
|
|
49
|
+
"cache_read_tokens",
|
|
50
|
+
"output_tokens",
|
|
51
|
+
"reasoning_tokens",
|
|
52
|
+
"total_tokens",
|
|
53
|
+
)
|
|
54
|
+
#: The two fields `normalize_usage` derives. Never read them from a provider.
|
|
55
|
+
DERIVED_USAGE_KEYS = ("cached_input_tokens", "total_tokens")
|
|
4
56
|
SUPPORTED_PROVIDERS = {"claude", "codex"}
|
|
5
57
|
|
|
6
58
|
|
|
@@ -8,6 +60,275 @@ def empty_usage():
|
|
|
8
60
|
return {key: None for key in USAGE_KEYS}
|
|
9
61
|
|
|
10
62
|
|
|
63
|
+
def normalize_usage(input_tokens=None, cache_creation_tokens=None, cache_read_tokens=None,
|
|
64
|
+
output_tokens=None, reasoning_tokens=None):
|
|
65
|
+
"""Build a usage record from the four measured quantities.
|
|
66
|
+
|
|
67
|
+
The derived fields are computed here and only here. A record whose parts are
|
|
68
|
+
all absent stays fully absent rather than becoming a row of zeros: a session
|
|
69
|
+
cdx could not measure must not read as a session that spent nothing.
|
|
70
|
+
"""
|
|
71
|
+
parts = {
|
|
72
|
+
"input_tokens": _non_negative(input_tokens),
|
|
73
|
+
"cache_creation_tokens": _non_negative(cache_creation_tokens),
|
|
74
|
+
"cache_read_tokens": _non_negative(cache_read_tokens),
|
|
75
|
+
"output_tokens": _non_negative(output_tokens),
|
|
76
|
+
"reasoning_tokens": _non_negative(reasoning_tokens),
|
|
77
|
+
}
|
|
78
|
+
if all(value is None for value in parts.values()):
|
|
79
|
+
return empty_usage()
|
|
80
|
+
cached = _sum_present(parts["cache_creation_tokens"], parts["cache_read_tokens"])
|
|
81
|
+
total = _sum_present(
|
|
82
|
+
parts["input_tokens"],
|
|
83
|
+
parts["cache_creation_tokens"],
|
|
84
|
+
parts["cache_read_tokens"],
|
|
85
|
+
parts["output_tokens"],
|
|
86
|
+
)
|
|
87
|
+
return {**parts, "cached_input_tokens": cached, "total_tokens": total}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
#: What a cached token costs relative to one uncached input token.
|
|
91
|
+
#:
|
|
92
|
+
#: Verified against both vendors on 2026-08-14 and identical on each: a cache
|
|
93
|
+
#: read is a 90% discount on the input rate, and a cache write is 1.25x it.
|
|
94
|
+
#: Anthropic's one-hour cache TTL costs 2x instead; nothing in a transcript
|
|
95
|
+
#: says which TTL was used, so long-TTL writes are under-weighted. Cache writes
|
|
96
|
+
#: are a small share of a cache-heavy session, so that error is bounded --
|
|
97
|
+
#: stated here rather than hidden behind a single averaged number.
|
|
98
|
+
CACHE_READ_MULTIPLIER = 0.1
|
|
99
|
+
CACHE_WRITE_MULTIPLIER = 1.25
|
|
100
|
+
|
|
101
|
+
#: What an output token costs relative to an uncached input one, when the model
|
|
102
|
+
#: is unknown. Anthropic bills 5x and OpenAI 6x, so an unknown model is priced
|
|
103
|
+
#: at the lower of the two: understating a cost cdx is guessing at is the safer
|
|
104
|
+
#: direction.
|
|
105
|
+
DEFAULT_OUTPUT_MULTIPLIER = 5.0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def output_multiplier(model, prices=None):
|
|
109
|
+
"""This model's output-to-input price ratio."""
|
|
110
|
+
entry = (prices if prices is not None else token_prices()[0]).get(model or "")
|
|
111
|
+
if not entry:
|
|
112
|
+
return DEFAULT_OUTPUT_MULTIPLIER
|
|
113
|
+
return entry["output"] / entry["input"]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def weighted_usage(usage, model=None, prices=None):
|
|
117
|
+
"""Consumption in uncached-input-equivalent tokens, or None if unknown.
|
|
118
|
+
|
|
119
|
+
A raw token total ranks a cache read equal to an output token worth fifty
|
|
120
|
+
times more, and cache reads are typically the overwhelming majority of
|
|
121
|
+
tokens -- so a raw total is very nearly a measure of cache reads alone.
|
|
122
|
+
That is not the ranking anyone reading `cdx stats` is looking for.
|
|
123
|
+
|
|
124
|
+
The output ratio comes from the model that served the run, because it is
|
|
125
|
+
the one multiplier that differs between vendors. The cache multipliers do
|
|
126
|
+
not, so they are constants.
|
|
127
|
+
|
|
128
|
+
`reasoning_tokens` is deliberately excluded: Codex reports it as a subset
|
|
129
|
+
of its output, and counting it would bill those tokens twice.
|
|
130
|
+
"""
|
|
131
|
+
if not isinstance(usage, dict):
|
|
132
|
+
return None
|
|
133
|
+
weights = {
|
|
134
|
+
"input_tokens": 1.0,
|
|
135
|
+
"cache_creation_tokens": CACHE_WRITE_MULTIPLIER,
|
|
136
|
+
"cache_read_tokens": CACHE_READ_MULTIPLIER,
|
|
137
|
+
"output_tokens": output_multiplier(model, prices),
|
|
138
|
+
}
|
|
139
|
+
present = [
|
|
140
|
+
weight * usage[key]
|
|
141
|
+
for key, weight in weights.items()
|
|
142
|
+
if usage.get(key) is not None
|
|
143
|
+
]
|
|
144
|
+
if not present:
|
|
145
|
+
return None
|
|
146
|
+
return int(round(sum(present)))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
#: List prices in dollars per million tokens: uncached input and output. The
|
|
150
|
+
#: cache classes are derived from the input rate by the multipliers above, an
|
|
151
|
+
#: invariant that holds on both vendors, so two numbers price a model.
|
|
152
|
+
#:
|
|
153
|
+
#: This is the one genuinely perishable input in cdx's usage accounting.
|
|
154
|
+
#: Override with CDX_TOKEN_PRICES (JSON: model -> {"input": x, "output": y})
|
|
155
|
+
#: rather than editing code, and treat an unknown model as unpriced instead of
|
|
156
|
+
#: assuming a tier. `scripts/check_token_prices.py` re-checks this table
|
|
157
|
+
#: against published pricing and a staleness test fails when nobody has;
|
|
158
|
+
#: `docs/token-prices-runbook.md` records where the numbers live and which
|
|
159
|
+
#: vendor pages refuse to be read.
|
|
160
|
+
#:
|
|
161
|
+
#: Anthropic figures come from the vendor's own reference. OpenAI figures were
|
|
162
|
+
#: taken from third-party aggregators on 2026-08-14 because openai.com refuses
|
|
163
|
+
#: automated fetches; four sources agreed on $2.50/$15 for Terra and one
|
|
164
|
+
#: outlier said $2/$12, so these carry more doubt than the Anthropic rows.
|
|
165
|
+
#:
|
|
166
|
+
#: Not modelled: OpenAI's long-context tier, which roughly doubles both rates
|
|
167
|
+
#: above a 272K-token request. cdx records tokens per run, not per request, so
|
|
168
|
+
#: it cannot tell which requests crossed that line -- long-context Codex work
|
|
169
|
+
#: is therefore under-costed here, and knowingly so.
|
|
170
|
+
DEFAULT_TOKEN_PRICES = {
|
|
171
|
+
# Anthropic
|
|
172
|
+
"claude-fable-5": {"input": 10.0, "output": 50.0},
|
|
173
|
+
"claude-mythos-5": {"input": 10.0, "output": 50.0},
|
|
174
|
+
"claude-opus-5": {"input": 5.0, "output": 25.0},
|
|
175
|
+
"claude-opus-4-8": {"input": 5.0, "output": 25.0},
|
|
176
|
+
"claude-opus-4-7": {"input": 5.0, "output": 25.0},
|
|
177
|
+
"claude-opus-4-6": {"input": 5.0, "output": 25.0},
|
|
178
|
+
"claude-sonnet-5": {"input": 3.0, "output": 15.0},
|
|
179
|
+
"claude-sonnet-4-6": {"input": 3.0, "output": 15.0},
|
|
180
|
+
"claude-haiku-4-5": {"input": 1.0, "output": 5.0},
|
|
181
|
+
# OpenAI
|
|
182
|
+
"gpt-5.6-sol": {"input": 5.0, "output": 30.0},
|
|
183
|
+
"gpt-5.6-terra": {"input": 2.5, "output": 15.0},
|
|
184
|
+
"gpt-5.6-luna": {"input": 1.0, "output": 6.0},
|
|
185
|
+
}
|
|
186
|
+
TOKEN_PRICES_REVIEWED = "2026-08-14"
|
|
187
|
+
#: How long a review stays fresh before the staleness test asks for another.
|
|
188
|
+
TOKEN_PRICES_MAX_AGE_DAYS = 90
|
|
189
|
+
TOKEN_PRICES_ENV = "CDX_TOKEN_PRICES"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def token_prices(env=None):
|
|
193
|
+
"""The price table in force, and where it came from."""
|
|
194
|
+
import os
|
|
195
|
+
|
|
196
|
+
environment = env if env is not None else os.environ
|
|
197
|
+
raw = environment.get(TOKEN_PRICES_ENV)
|
|
198
|
+
if raw:
|
|
199
|
+
try:
|
|
200
|
+
overrides = json.loads(raw)
|
|
201
|
+
except ValueError:
|
|
202
|
+
return dict(DEFAULT_TOKEN_PRICES), f"built-in, reviewed {TOKEN_PRICES_REVIEWED} ({TOKEN_PRICES_ENV} ignored: not JSON)"
|
|
203
|
+
if isinstance(overrides, dict):
|
|
204
|
+
merged = {**DEFAULT_TOKEN_PRICES}
|
|
205
|
+
for name, entry in overrides.items():
|
|
206
|
+
if isinstance(entry, dict) and "input" in entry and "output" in entry:
|
|
207
|
+
merged[str(name)] = {
|
|
208
|
+
"input": float(entry["input"]), "output": float(entry["output"])}
|
|
209
|
+
return merged, TOKEN_PRICES_ENV
|
|
210
|
+
return dict(DEFAULT_TOKEN_PRICES), f"built-in, reviewed {TOKEN_PRICES_REVIEWED}"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def estimate_cost(usage, model, prices=None):
|
|
214
|
+
"""Dollars this usage would list at, or None when it cannot be priced.
|
|
215
|
+
|
|
216
|
+
Each token class is priced directly rather than through the weighted
|
|
217
|
+
figure. That shortcut was only correct while every model shared one
|
|
218
|
+
output-to-input ratio, and it does not: Anthropic bills 5x, OpenAI 6x, so
|
|
219
|
+
it under-costed Codex output by a fifth.
|
|
220
|
+
|
|
221
|
+
ponytail: a run is priced at one model -- the one serving its most recent
|
|
222
|
+
record -- rather than split per model. A session that switches models
|
|
223
|
+
mid-run is priced at the newer one. Upgrade path if that matters: have the
|
|
224
|
+
readers return a per-model breakdown and difference each model separately.
|
|
225
|
+
|
|
226
|
+
An unknown or absent model yields None. Pricing it at a default tier would
|
|
227
|
+
turn "cdx does not know" into a number someone might believe.
|
|
228
|
+
"""
|
|
229
|
+
if not model or not isinstance(usage, dict):
|
|
230
|
+
return None
|
|
231
|
+
rate = (prices if prices is not None else token_prices()[0]).get(model)
|
|
232
|
+
if rate is None:
|
|
233
|
+
return None
|
|
234
|
+
per_million = {
|
|
235
|
+
"input_tokens": rate["input"],
|
|
236
|
+
"cache_read_tokens": rate["input"] * CACHE_READ_MULTIPLIER,
|
|
237
|
+
"cache_creation_tokens": rate["input"] * CACHE_WRITE_MULTIPLIER,
|
|
238
|
+
"output_tokens": rate["output"],
|
|
239
|
+
}
|
|
240
|
+
present = [
|
|
241
|
+
price * usage[key] for key, price in per_million.items()
|
|
242
|
+
if usage.get(key) is not None
|
|
243
|
+
]
|
|
244
|
+
if not present:
|
|
245
|
+
return None
|
|
246
|
+
return sum(present) / 1_000_000
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def claude_usage_dedup_key(record):
|
|
250
|
+
"""What identifies one *billed* Claude API response in a transcript.
|
|
251
|
+
|
|
252
|
+
Not the record's `uuid`. A Claude Code transcript writes the same assistant
|
|
253
|
+
response under several uuids -- measured on one real 2741-row transcript:
|
|
254
|
+
2741 distinct uuids for 1765 distinct responses, so 36% of the rows were
|
|
255
|
+
repeats of a call the account paid for once. Summing per uuid inflated that
|
|
256
|
+
file's tokens by about 1.55x against an independent reader of the same
|
|
257
|
+
bytes.
|
|
258
|
+
|
|
259
|
+
The billed unit is the API response, so the key is the message id paired
|
|
260
|
+
with the request that produced it. `uuid` remains the fallback for records
|
|
261
|
+
that carry neither, which is the older transcript shape.
|
|
262
|
+
|
|
263
|
+
Returns None when the record carries no identity at all. That means "cannot
|
|
264
|
+
prove this is a repeat", and the caller must count it: two identity-less
|
|
265
|
+
records collapsing into one would discard usage that was really spent,
|
|
266
|
+
which is a worse error than counting a duplicate.
|
|
267
|
+
"""
|
|
268
|
+
message = record.get("message") or {}
|
|
269
|
+
message_id = message.get("id")
|
|
270
|
+
request_id = record.get("requestId")
|
|
271
|
+
if message_id and request_id:
|
|
272
|
+
return ("api", message_id, request_id)
|
|
273
|
+
if message_id:
|
|
274
|
+
return ("message", message_id)
|
|
275
|
+
uuid = record.get("uuid")
|
|
276
|
+
return ("uuid", uuid) if uuid else None
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def is_vouched(usage):
|
|
280
|
+
"""Whether this record was written under the current field definition.
|
|
281
|
+
|
|
282
|
+
Records that predate it are not imprecise, they are fictitious: the reader
|
|
283
|
+
picked whichever transcript had the newest mtime and re-read all of it on
|
|
284
|
+
every launch. They are filtered out of aggregates rather than displayed,
|
|
285
|
+
because a wrong figure carries the same authority as a right one.
|
|
286
|
+
|
|
287
|
+
The marker is the presence of `cache_creation_tokens`, which
|
|
288
|
+
`normalize_usage` always emits and nothing before it did. Deliberately not
|
|
289
|
+
`usage_cumulative`: headless runs legitimately have none.
|
|
290
|
+
"""
|
|
291
|
+
return isinstance(usage, dict) and "cache_creation_tokens" in usage
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def coerce_usage(usage):
|
|
295
|
+
"""Bring an already-built usage record onto the canonical shape.
|
|
296
|
+
|
|
297
|
+
For boundaries that receive a record rather than measure one -- the run
|
|
298
|
+
registry, which publishes what it stores. Missing fields become absent
|
|
299
|
+
rather than zero, and the derived figures are recomputed from the parts, so
|
|
300
|
+
a record built before this definition existed cannot carry a stale total
|
|
301
|
+
into the JSON surfaces.
|
|
302
|
+
"""
|
|
303
|
+
if not isinstance(usage, dict):
|
|
304
|
+
return None
|
|
305
|
+
record = normalize_usage(
|
|
306
|
+
input_tokens=usage.get("input_tokens"),
|
|
307
|
+
cache_creation_tokens=usage.get("cache_creation_tokens"),
|
|
308
|
+
cache_read_tokens=usage.get("cache_read_tokens"),
|
|
309
|
+
output_tokens=usage.get("output_tokens"),
|
|
310
|
+
reasoning_tokens=usage.get("reasoning_tokens"),
|
|
311
|
+
)
|
|
312
|
+
if record["cached_input_tokens"] is None:
|
|
313
|
+
# A legacy record carrying only the fused figure: keep it visible rather
|
|
314
|
+
# than dropping a number someone already recorded, but leave the split
|
|
315
|
+
# absent because it genuinely is not recoverable.
|
|
316
|
+
record["cached_input_tokens"] = _first_int(usage.get("cached_input_tokens"))
|
|
317
|
+
return record
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _non_negative(value):
|
|
321
|
+
parsed = _first_int(value)
|
|
322
|
+
return parsed
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _sum_present(*values):
|
|
326
|
+
present = [value for value in values if value is not None]
|
|
327
|
+
if not present:
|
|
328
|
+
return None
|
|
329
|
+
return sum(present)
|
|
330
|
+
|
|
331
|
+
|
|
11
332
|
def extract_run_usage(provider, stdout_path):
|
|
12
333
|
if not stdout_path or not provider:
|
|
13
334
|
return empty_usage()
|
|
@@ -79,40 +400,67 @@ def _find_usage(value):
|
|
|
79
400
|
|
|
80
401
|
|
|
81
402
|
def _usage_from_dict(value):
|
|
403
|
+
"""Map one provider usage object onto the canonical fields.
|
|
404
|
+
|
|
405
|
+
This used to fold the cache counts into `input_tokens` *and* report them
|
|
406
|
+
again as `cached_input_tokens`, so IN and CACHE both counted the same
|
|
407
|
+
tokens. The fix is not arithmetic in this function: it is that the split
|
|
408
|
+
parts go to `normalize_usage`, which owns every derived figure.
|
|
409
|
+
"""
|
|
82
410
|
usage = value.get("usage") if isinstance(value.get("usage"), dict) else value
|
|
83
411
|
if not isinstance(usage, dict):
|
|
84
412
|
return empty_usage()
|
|
85
413
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
usage.get("
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
414
|
+
cache_creation = _first_int(usage.get("cache_creation_input_tokens"))
|
|
415
|
+
cache_read = _first_int(usage.get("cache_read_input_tokens"))
|
|
416
|
+
if cache_creation is None and cache_read is None:
|
|
417
|
+
# Codex reports one cached figure with no creation/read split; it is a
|
|
418
|
+
# read by construction, since nothing else could have written it.
|
|
419
|
+
cache_read = _first_int(usage.get("cached_input_tokens"))
|
|
420
|
+
|
|
421
|
+
input_tokens = _int_value(usage.get("input_tokens"), usage.get("prompt_tokens"))
|
|
422
|
+
input_tokens = _uncached_input(input_tokens, cache_read, usage)
|
|
423
|
+
|
|
424
|
+
return normalize_usage(
|
|
425
|
+
input_tokens=input_tokens,
|
|
426
|
+
cache_creation_tokens=cache_creation,
|
|
427
|
+
cache_read_tokens=cache_read,
|
|
428
|
+
output_tokens=_int_value(usage.get("output_tokens"), usage.get("completion_tokens")),
|
|
429
|
+
reasoning_tokens=_int_value(
|
|
430
|
+
usage.get("reasoning_tokens"),
|
|
431
|
+
usage.get("reasoning_output_tokens"),
|
|
432
|
+
_nested_int(usage, "output_tokens_details", "reasoning_tokens"),
|
|
433
|
+
_nested_int(usage, "completion_tokens_details", "reasoning_tokens"),
|
|
434
|
+
),
|
|
104
435
|
)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _uncached_input(input_tokens, cache_read, usage):
|
|
439
|
+
"""Reduce a cache-inclusive input count to its uncached remainder.
|
|
440
|
+
|
|
441
|
+
Two dialects reach this function. Anthropic reports `input_tokens` as the
|
|
442
|
+
uncached remainder already and carries the `cache_*_input_tokens` pair.
|
|
443
|
+
OpenAI-shaped records -- Codex among them -- report a bare
|
|
444
|
+
`cached_input_tokens` that is a *subset* of `input_tokens`, so leaving it
|
|
445
|
+
alone would count those tokens in both IN and CACHE.
|
|
446
|
+
|
|
447
|
+
The subset relationship is the assumption, and it is checked rather than
|
|
448
|
+
trusted: a record where the cached count exceeds the input count cannot be
|
|
449
|
+
a subset, so that record is left alone instead of being forced to zero.
|
|
450
|
+
Guessing wrong in that direction would silently erase real input tokens,
|
|
451
|
+
which is the failure this whole definition exists to stop.
|
|
452
|
+
"""
|
|
453
|
+
if input_tokens is None or not cache_read:
|
|
454
|
+
return input_tokens
|
|
455
|
+
if usage.get("cache_creation_input_tokens") is not None:
|
|
456
|
+
return input_tokens
|
|
457
|
+
if usage.get("cache_read_input_tokens") is not None:
|
|
458
|
+
return input_tokens
|
|
459
|
+
if usage.get("cached_input_tokens") is None:
|
|
460
|
+
return input_tokens
|
|
461
|
+
if cache_read > input_tokens:
|
|
462
|
+
return input_tokens
|
|
463
|
+
return input_tokens - cache_read
|
|
116
464
|
|
|
117
465
|
|
|
118
466
|
def _nested_int(value, parent, child):
|
package/src/session_service.py
CHANGED
|
@@ -789,7 +789,15 @@ def finish_session_runtime(store, name, run_id=None, payload=None):
|
|
|
789
789
|
return outcome.get("runtime")
|
|
790
790
|
|
|
791
791
|
|
|
792
|
-
def launch_session(store, name):
|
|
792
|
+
def launch_session(store, name, resume=False):
|
|
793
|
+
"""Mark a session launched, minting a new conversation id when appropriate.
|
|
794
|
+
|
|
795
|
+
`resume` exists because minting is exactly wrong there: a resume must carry
|
|
796
|
+
the conversation it is resuming. Without this, `cdx <session> --resume`
|
|
797
|
+
minted a fresh id, stored it, and then asked the provider to resume a
|
|
798
|
+
conversation that had never existed -- "No conversation found with session
|
|
799
|
+
ID" on every attempt, for Claude.
|
|
800
|
+
"""
|
|
793
801
|
session = store["get_session"](name)
|
|
794
802
|
if not session:
|
|
795
803
|
raise CdxError(f"Unknown session: {name}")
|
|
@@ -806,7 +814,7 @@ def launch_session(store, name):
|
|
|
806
814
|
|
|
807
815
|
def session_updater(s):
|
|
808
816
|
updated = {**s, "updatedAt": now, "lastLaunchedAt": now}
|
|
809
|
-
conversation = _conversation_identity_for_launch(s)
|
|
817
|
+
conversation = None if resume else _conversation_identity_for_launch(s)
|
|
810
818
|
if conversation:
|
|
811
819
|
updated["conversation"] = conversation
|
|
812
820
|
return updated
|
|
@@ -912,6 +920,7 @@ def create_session_service(options=None):
|
|
|
912
920
|
"record_status": partial(record_status, store),
|
|
913
921
|
"record_launch_history": partial(record_launch_history, store),
|
|
914
922
|
"get_launch_history": partial(get_launch_history, store),
|
|
923
|
+
"drop_unvouched_usage": store["drop_unvouched_usage"],
|
|
915
924
|
"update_auth_state": partial(update_auth_state, store),
|
|
916
925
|
"get_status_row": partial(get_status_row, store, base_dir, env, fetch_codex_status),
|
|
917
926
|
"get_status_rows": partial(get_status_rows, store, base_dir, env, fetch_codex_status),
|
package/src/session_store.py
CHANGED
|
@@ -6,6 +6,7 @@ from contextlib import contextmanager
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
8
|
from .errors import CdxError
|
|
9
|
+
from .fs_utils import atomic_write
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
def _ensure_dir(path):
|
|
@@ -280,6 +281,58 @@ def create_session_store(base_dir):
|
|
|
280
281
|
os.fsync(handle.fileno())
|
|
281
282
|
_fsync_directory(state_dir)
|
|
282
283
|
|
|
284
|
+
def drop_unvouched_usage(dry_run=True):
|
|
285
|
+
"""Strip usage from launch-history records written before the definition.
|
|
286
|
+
|
|
287
|
+
Those records are not imprecise, they are fictitious: the reader picked
|
|
288
|
+
whichever transcript had the newest mtime and re-read all of it on
|
|
289
|
+
every launch, so one session reported 206.6M cached tokens for a period
|
|
290
|
+
in which its own conversation consumed 33,608.
|
|
291
|
+
|
|
292
|
+
They cannot be recomputed -- a run's true usage is the increment its
|
|
293
|
+
transcript gained while it ran, and nothing recorded the transcript's
|
|
294
|
+
state at that moment. So the figures go and everything else stays: a
|
|
295
|
+
run that happened still happened, with its duration, status and time.
|
|
296
|
+
|
|
297
|
+
The discriminator is the absence of `cache_creation_tokens`, which
|
|
298
|
+
`normalize_usage` always emits and nothing before it did. Not the
|
|
299
|
+
absence of `usage_cumulative`: headless runs legitimately have none,
|
|
300
|
+
and keying on it would drop correct records.
|
|
301
|
+
"""
|
|
302
|
+
with _file_lock(lock_file):
|
|
303
|
+
try:
|
|
304
|
+
with open(launch_history_file, encoding="utf-8") as handle:
|
|
305
|
+
lines = handle.readlines()
|
|
306
|
+
except FileNotFoundError:
|
|
307
|
+
return {"scanned": 0, "dropped": 0, "dry_run": dry_run}
|
|
308
|
+
scanned = dropped = 0
|
|
309
|
+
rewritten = []
|
|
310
|
+
for line in lines:
|
|
311
|
+
stripped = line.strip()
|
|
312
|
+
if not stripped:
|
|
313
|
+
continue
|
|
314
|
+
try:
|
|
315
|
+
entry = json.loads(stripped)
|
|
316
|
+
except ValueError:
|
|
317
|
+
# Unreadable lines are left exactly as they are: this
|
|
318
|
+
# removes figures, it does not tidy the file.
|
|
319
|
+
rewritten.append(line)
|
|
320
|
+
continue
|
|
321
|
+
scanned += 1
|
|
322
|
+
usage = entry.get("usage")
|
|
323
|
+
if isinstance(usage, dict) and "cache_creation_tokens" not in usage:
|
|
324
|
+
dropped += 1
|
|
325
|
+
entry.pop("usage", None)
|
|
326
|
+
entry.pop("usage_cumulative", None)
|
|
327
|
+
entry["usage_dropped_reason"] = "recorded_before_field_definition"
|
|
328
|
+
line = json.dumps(entry, separators=(",", ":")) + "\n"
|
|
329
|
+
rewritten.append(line)
|
|
330
|
+
if not dry_run and dropped:
|
|
331
|
+
_ensure_dir(state_dir)
|
|
332
|
+
atomic_write(launch_history_file, "".join(rewritten))
|
|
333
|
+
_fsync_directory(state_dir)
|
|
334
|
+
return {"scanned": scanned, "dropped": dropped, "dry_run": dry_run}
|
|
335
|
+
|
|
283
336
|
def list_launch_history(session_name=None, limit=20):
|
|
284
337
|
with _file_lock(lock_file):
|
|
285
338
|
try:
|
|
@@ -315,5 +368,6 @@ def create_session_store(base_dir):
|
|
|
315
368
|
"write_session_state": write_session_state,
|
|
316
369
|
"update_session_state": update_session_state,
|
|
317
370
|
"append_launch_history": append_launch_history,
|
|
371
|
+
"drop_unvouched_usage": drop_unvouched_usage,
|
|
318
372
|
"list_launch_history": list_launch_history,
|
|
319
373
|
}
|