cdx-manager 0.24.7 → 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 +26 -1
- package/VERSION +1 -1
- package/changelogs/CHANGELOGS_0_24_7.md +7 -0
- package/changelogs/CHANGELOGS_0_24_8.md +24 -0
- package/changelogs/CHANGELOGS_0_24_9.md +18 -0
- package/checksums/release-archives.json +22 -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 +12 -2
- package/src/commands/launch.py +4 -4
- package/src/commands/runs.py +208 -21
- 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 +15 -2
- package/src/run_failover.py +32 -2
- package/src/run_registry.py +24 -2
- package/src/run_usage.py +89 -24
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,9 +218,12 @@ 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
|
-
"model": parsed.get("model") or ((session.get("launch") or {}).get("model") if session else None),
|
|
225
|
+
"model": run_info.get("effective_model", parsed.get("model") or ((session.get("launch") or {}).get("model") if session else None)),
|
|
226
|
+
"recovery_events": run_info.get("recovery_events", []),
|
|
214
227
|
"reasoning_effort": run_payload_reasoning_effort(parsed, session),
|
|
215
228
|
"power": parsed.get("power") or ((session.get("launch") or {}).get("power") if session else None),
|
|
216
229
|
"cwd": os.path.abspath(parsed.get("cwd") or os.getcwd()),
|
package/src/run_failover.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Classify finished runs for account exhaustion or model capacity recovery.
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
For account exhaustion, two independent signals must agree before cdx moves a task to another
|
|
4
4
|
account, because the two ways of being wrong are not symmetric. Missing a rate
|
|
5
5
|
limit costs nothing new: the run fails exactly as it does today. Inventing one
|
|
6
6
|
migrates a healthy run off a working account, discards its progress, and
|
|
@@ -22,6 +22,7 @@ codebase reads provider output for this purpose.
|
|
|
22
22
|
|
|
23
23
|
import json
|
|
24
24
|
import os
|
|
25
|
+
import re
|
|
25
26
|
|
|
26
27
|
from .config import PROVIDER_CLAUDE, PROVIDER_CODEX
|
|
27
28
|
|
|
@@ -235,3 +236,32 @@ def failover_reason(provider, run_info, status_row):
|
|
|
235
236
|
|
|
236
237
|
def should_fail_over(provider, run_info, status_row):
|
|
237
238
|
return failover_reason(provider, run_info, status_row) is not None
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
CAPACITY_RETRY_DELAYS = (1, 2)
|
|
242
|
+
_CAPACITY_MESSAGE = re.compile(r"(?:selected model|(?:the )?model(?: [\w.-]+)?) is at capacity\b", re.IGNORECASE)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def looks_model_at_capacity(provider, run_info):
|
|
246
|
+
"""Recognize Codex failures, excluding successful output and assistant prose."""
|
|
247
|
+
if provider != PROVIDER_CODEX or run_info.get("timed_out") or run_info.get("returncode") in (None, 0):
|
|
248
|
+
return False
|
|
249
|
+
for key in ("stdout_path", "stderr_path"):
|
|
250
|
+
text = _read_tail(run_info.get(key))
|
|
251
|
+
for record in _structured_records(provider, text):
|
|
252
|
+
if any(_CAPACITY_MESSAGE.search(message) for message in _error_texts(provider, record)):
|
|
253
|
+
return True
|
|
254
|
+
# Some Codex versions print this diagnostic as a standalone plain line.
|
|
255
|
+
for line in text.splitlines():
|
|
256
|
+
line = line.strip()
|
|
257
|
+
if _CAPACITY_MESSAGE.match(re.sub(r"^error:\s*", "", line, flags=re.IGNORECASE)):
|
|
258
|
+
return True
|
|
259
|
+
return False
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def capacity_models(session):
|
|
263
|
+
"""Configured alternatives in order, without retrying the current model."""
|
|
264
|
+
launch = session.get("launch") or {}
|
|
265
|
+
models = [launch.get("model")]
|
|
266
|
+
models.extend(str(launch.get("fallback_model") or "").split(","))
|
|
267
|
+
return list(dict.fromkeys(model.strip() if model else None for model in models if model is None or model.strip()))
|
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
|
|
@@ -321,6 +326,16 @@ class RunRegistry:
|
|
|
321
326
|
return run
|
|
322
327
|
return None
|
|
323
328
|
|
|
329
|
+
def record_recovery(self, run_id, event):
|
|
330
|
+
"""Publish retries and fallbacks while a supervised run is still active."""
|
|
331
|
+
with _registry_lock(self.path):
|
|
332
|
+
data = _read_registry(self.path)
|
|
333
|
+
for run in data["runs"]:
|
|
334
|
+
if run.get("run_id") == run_id:
|
|
335
|
+
run.setdefault("recovery_events", []).append({"at": utc_now_iso(), **event})
|
|
336
|
+
_write_registry(self.path, data)
|
|
337
|
+
return
|
|
338
|
+
|
|
324
339
|
def finish(self, run_id, *, status, final_payload=None, run_info=None, error=None, task_report=None):
|
|
325
340
|
with _registry_lock(self.path):
|
|
326
341
|
return self._finish_locked(run_id, status=status, final_payload=final_payload, run_info=run_info, error=error, task_report=task_report)
|
|
@@ -346,6 +361,8 @@ class RunRegistry:
|
|
|
346
361
|
run["duration_seconds"] = None
|
|
347
362
|
if run_info:
|
|
348
363
|
run["pid"] = run_info.get("pid")
|
|
364
|
+
if run_info.get("launch"):
|
|
365
|
+
run["launch"] = run_info["launch"]
|
|
349
366
|
run["exit_code"] = run_info.get("returncode")
|
|
350
367
|
run["artifacts"] = {
|
|
351
368
|
**(run.get("artifacts") or {}),
|
|
@@ -357,8 +374,13 @@ class RunRegistry:
|
|
|
357
374
|
# Normalize on the way in, not on the way out: this record is
|
|
358
375
|
# what `cdx run-status` and `cdx run-report` publish, and it
|
|
359
376
|
# used to store whichever shape the producing reader emitted.
|
|
360
|
-
run["usage"] = coerce_usage(final_payload.get("usage"))
|
|
377
|
+
run["usage"] = coerce_usage(final_payload.get("usage"), provider=run.get("provider"))
|
|
361
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"]
|
|
383
|
+
run["model"] = final_payload.get("model")
|
|
362
384
|
run["final_payload"] = final_payload
|
|
363
385
|
if error:
|
|
364
386
|
run["error"] = 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):
|