cdx-manager 0.20.14 → 0.21.1

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.
@@ -219,13 +219,14 @@ def read_transcript_outcome(path):
219
219
  records the same facts here. Usage is summed across assistant messages
220
220
  because a session can hold several turns.
221
221
  """
222
- outcome = {"result": None, "usage": None}
222
+ outcome = {"result": None, "usage": None, "usage_model": None}
223
223
  if not path:
224
224
  return outcome
225
225
  totals = {"input_tokens": 0, "cache_creation_tokens": 0, "cache_read_tokens": 0, "output_tokens": 0}
226
226
  # This path had no de-duplication at all, so a transcript that records the
227
227
  # same billed response more than once inflated the total outright.
228
228
  counted = set()
229
+ models = set()
229
230
  seen = False
230
231
  try:
231
232
  with open(path, encoding="utf-8", errors="replace") as handle:
@@ -261,6 +262,8 @@ def read_transcript_outcome(path):
261
262
  # interactive one filled the same column under opposite
262
263
  # definitions.
263
264
  seen = True
265
+ if isinstance(message.get("model"), str) and message["model"]:
266
+ models.add(message["model"])
264
267
  totals["input_tokens"] += usage.get("input_tokens") or 0
265
268
  totals["cache_creation_tokens"] += usage.get("cache_creation_input_tokens") or 0
266
269
  totals["cache_read_tokens"] += usage.get("cache_read_input_tokens") or 0
@@ -269,4 +272,6 @@ def read_transcript_outcome(path):
269
272
  return outcome
270
273
  if seen:
271
274
  outcome["usage"] = normalize_usage(**totals)
275
+ if len(models) == 1:
276
+ outcome["usage_model"] = next(iter(models))
272
277
  return outcome
@@ -515,7 +515,7 @@ def _list_launch_transcript_paths(session, glob_fn=None):
515
515
  log_dir = _get_launch_transcript_dir(session)
516
516
  if not os.path.isdir(log_dir):
517
517
  return []
518
- paths = set(glob_fn(os.path.join(log_dir, "cdx-session*.log")))
518
+ paths = set(glob_fn(os.path.join(glob.escape(log_dir), "cdx-session*.log")))
519
519
  legacy = _get_launch_transcript_path(session)
520
520
  if os.path.exists(legacy):
521
521
  paths.add(legacy)
@@ -28,7 +28,7 @@ REGISTRY_LOCK_TIMEOUT_SECONDS = 120
28
28
  _LOCK_RETRY_SECONDS = 0.05
29
29
 
30
30
 
31
- def _acquire_windows_lock(handle, timeout_seconds):
31
+ def _acquire_windows_lock(handle, timeout_seconds, label="run registry"):
32
32
  """Block until the byte is locked, or give up with a nameable error.
33
33
 
34
34
  Uses the non-blocking form and does the waiting here, so the wait is
@@ -47,14 +47,14 @@ def _acquire_windows_lock(handle, timeout_seconds):
47
47
  except OSError:
48
48
  if time.monotonic() >= deadline:
49
49
  raise CdxError(
50
- "Timed out waiting for the run registry lock after "
50
+ f"Timed out waiting for the {label} lock after "
51
51
  f"{timeout_seconds}s. Another cdx process may be stuck holding it.",
52
52
  75,
53
53
  ) from None
54
54
  time.sleep(_LOCK_RETRY_SECONDS)
55
55
 
56
56
 
57
- def _acquire_posix_lock(handle, timeout_seconds):
57
+ def _acquire_posix_lock(handle, timeout_seconds, label="run registry"):
58
58
  """Acquire a POSIX advisory lock within the same bounded wait as Windows."""
59
59
  import fcntl
60
60
 
@@ -66,7 +66,7 @@ def _acquire_posix_lock(handle, timeout_seconds):
66
66
  except OSError:
67
67
  if time.monotonic() >= deadline:
68
68
  raise CdxError(
69
- "Timed out waiting for the run registry lock after "
69
+ f"Timed out waiting for the {label} lock after "
70
70
  f"{timeout_seconds}s. Another cdx process may be stuck holding it.",
71
71
  75,
72
72
  ) from None
@@ -74,12 +74,12 @@ def _acquire_posix_lock(handle, timeout_seconds):
74
74
 
75
75
 
76
76
  @contextmanager
77
- def _registry_lock(path, timeout_seconds=REGISTRY_LOCK_TIMEOUT_SECONDS):
77
+ def _registry_lock(path, timeout_seconds=REGISTRY_LOCK_TIMEOUT_SECONDS, label="run registry"):
78
78
  os.makedirs(os.path.dirname(path), exist_ok=True)
79
79
  with open(path + ".lock", "a") as handle:
80
80
  if sys.platform == "win32":
81
81
  import msvcrt
82
- _acquire_windows_lock(handle, timeout_seconds)
82
+ _acquire_windows_lock(handle, timeout_seconds, label)
83
83
  try:
84
84
  yield
85
85
  finally:
@@ -87,7 +87,7 @@ def _registry_lock(path, timeout_seconds=REGISTRY_LOCK_TIMEOUT_SECONDS):
87
87
  msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
88
88
  else:
89
89
  import fcntl
90
- _acquire_posix_lock(handle, timeout_seconds)
90
+ _acquire_posix_lock(handle, timeout_seconds, label)
91
91
  try:
92
92
  yield
93
93
  finally:
package/src/run_usage.py CHANGED
@@ -40,6 +40,9 @@ caller can invent a fourth arithmetic.
40
40
  """
41
41
 
42
42
  import json
43
+ import math
44
+
45
+ from .errors import CdxError
43
46
 
44
47
  #: Order matters only for readability; every consumer addresses these by name.
45
48
  USAGE_KEYS = (
@@ -109,15 +112,19 @@ def output_multiplier(model, prices=None):
109
112
  entry = (prices if prices is not None else token_prices()[0]).get(model or "")
110
113
  if not entry:
111
114
  return DEFAULT_OUTPUT_MULTIPLIER
112
- return entry["output"] / entry["input"]
115
+ return entry["output"] / entry["input"] if entry["input"] else None
113
116
 
114
117
 
115
118
  def cache_read_multiplier(model, prices=None):
116
119
  """This model's cache-read/input ratio, with the legacy fallback."""
117
120
  entry = (prices if prices is not None else token_prices()[0]).get(model or "")
118
- if not entry or "cache_read" not in entry:
121
+ if not entry:
119
122
  return CACHE_READ_MULTIPLIER
120
- return entry["cache_read"] / entry["input"]
123
+ if not entry["input"]:
124
+ return None
125
+ if "cache_read" not in entry:
126
+ return CACHE_READ_MULTIPLIER
127
+ return entry["cache_read"] / entry["input"] if entry["input"] else None
121
128
 
122
129
 
123
130
  def weighted_usage(usage, model=None, prices=None):
@@ -142,11 +149,13 @@ def weighted_usage(usage, model=None, prices=None):
142
149
  "cache_read_tokens": cache_read_multiplier(model, prices),
143
150
  "output_tokens": output_multiplier(model, prices),
144
151
  }
145
- present = [
146
- weight * usage[key]
147
- for key, weight in weights.items()
148
- if usage.get(key) is not None
149
- ]
152
+ present = []
153
+ for key, weight in weights.items():
154
+ value = usage.get(key)
155
+ if value is not None:
156
+ if weight is None and value:
157
+ return None
158
+ present.append((weight or 0) * value)
150
159
  if not present:
151
160
  return None
152
161
  return int(round(sum(present)))
@@ -224,17 +233,26 @@ def token_prices(env=None):
224
233
  if raw:
225
234
  try:
226
235
  overrides = json.loads(raw)
227
- except ValueError:
228
- return dict(DEFAULT_TOKEN_PRICES), f"built-in, reviewed {TOKEN_PRICES_REVIEWED} ({TOKEN_PRICES_ENV} ignored: not JSON)"
229
- if isinstance(overrides, dict):
230
- merged = {**DEFAULT_TOKEN_PRICES}
231
- for name, entry in overrides.items():
232
- if isinstance(entry, dict) and "input" in entry and "output" in entry:
233
- merged[str(name)] = {
234
- "input": float(entry["input"]), "output": float(entry["output"])}
235
- if "cache_read" in entry:
236
- merged[str(name)]["cache_read"] = float(entry["cache_read"])
237
- return merged, TOKEN_PRICES_ENV
236
+ except ValueError as error:
237
+ raise CdxError(f"{TOKEN_PRICES_ENV} must be a JSON object of model rates.") from error
238
+ if not isinstance(overrides, dict):
239
+ raise CdxError(f"{TOKEN_PRICES_ENV} must be a JSON object of model rates.")
240
+ merged = {**DEFAULT_TOKEN_PRICES}
241
+ for name, entry in overrides.items():
242
+ if not isinstance(entry, dict) or not {"input", "output"} <= entry.keys():
243
+ raise CdxError(f"{TOKEN_PRICES_ENV}: {name} requires input and output rates.")
244
+ rates = {}
245
+ for key in ("input", "output", "cache_read"):
246
+ if key not in entry:
247
+ continue
248
+ value = entry[key]
249
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
250
+ value = float("nan")
251
+ if not math.isfinite(value) or value < 0:
252
+ raise CdxError(f"{TOKEN_PRICES_ENV}: {name}.{key} must be a finite nonnegative rate.")
253
+ rates[key] = value
254
+ merged[str(name)] = rates
255
+ return merged, TOKEN_PRICES_ENV
238
256
  return dict(DEFAULT_TOKEN_PRICES), f"built-in, reviewed {TOKEN_PRICES_REVIEWED}"
239
257
 
240
258
 
@@ -245,10 +263,8 @@ def estimate_cost(usage, model, prices=None):
245
263
  figure. That shortcut was only correct while every model shared one
246
264
  output-to-input ratio, and they do not.
247
265
 
248
- ponytail: a run is priced at one model -- the one serving its most recent
249
- record -- rather than split per model. A session that switches models
250
- mid-run is priced at the newer one. Upgrade path if that matters: have the
251
- readers return a per-model breakdown and difference each model separately.
266
+ A run with mixed observed models and no measured per-model breakdown has
267
+ no usage_model, so its usage remains visible but unpriced.
252
268
 
253
269
  An unknown or absent model yields None. Pricing it at a default tier would
254
270
  turn "cdx does not know" into a number someone might believe.
@@ -357,26 +373,41 @@ def _sum_present(*values):
357
373
 
358
374
 
359
375
  def extract_run_usage(provider, stdout_path):
376
+ return extract_run_observation(provider, stdout_path)[0]
377
+
378
+
379
+ def extract_run_observation(provider, stdout_path):
360
380
  if not stdout_path or not provider:
361
- return empty_usage()
381
+ return empty_usage(), None
362
382
  if provider not in SUPPORTED_PROVIDERS:
363
- return empty_usage()
383
+ return empty_usage(), None
364
384
  try:
365
385
  with open(stdout_path, encoding="utf-8", errors="replace") as handle:
366
386
  text = handle.read()
367
387
  except OSError:
368
- return empty_usage()
388
+ return empty_usage(), None
369
389
  if not text.strip():
370
- return empty_usage()
390
+ return empty_usage(), None
371
391
 
372
392
  records = _parse_json_records(text)
373
393
  if not records:
374
- return empty_usage()
394
+ return empty_usage(), None
375
395
 
376
396
  usage = _extract_usage_from_records(records)
377
397
  if not _has_usage(usage):
378
- return empty_usage()
379
- return usage
398
+ return empty_usage(), None
399
+ models = set()
400
+ for record in records:
401
+ if not isinstance(record, dict):
402
+ continue
403
+ message = record.get("message")
404
+ model = record.get("model") or (message.get("model") if isinstance(message, dict) else None)
405
+ if isinstance(model, str) and model and _has_usage(_find_usage(record)):
406
+ models.add(model)
407
+ model_usage = record.get("modelUsage")
408
+ if isinstance(model_usage, dict):
409
+ models.update(model_usage)
410
+ return usage, next(iter(models)) if len(models) == 1 else None
380
411
 
381
412
 
382
413
  def _parse_json_records(text):
@@ -306,12 +306,20 @@ def import_bundle(base_dir, store, file_path,
306
306
  old_record = None
307
307
  old_state = None
308
308
  preserved_profile_paths = []
309
+ # Read the live credential before moving its profile: a denied keychain
310
+ # read must leave the registered session untouched.
311
+ local_credential = None
309
312
  if is_existing and (force or merge):
310
313
  old_record = store["get_session"](name)
311
314
  old_state = store["read_session_state"](name)
312
315
  if merge:
313
316
  provider = old_record["provider"]
314
317
  auth_home = old_record["authHome"]
318
+ bundle_carries_credential = provider == PROVIDER_CLAUDE and any(
319
+ item["path"] == CLAUDE_CREDENTIALS_BUNDLE_PATH for item in decoded_profiles.get(name, [])
320
+ )
321
+ if bundle_carries_credential:
322
+ local_credential = read_keychain_credentials(old_record["authHome"])
315
323
  recovery_dir = tempfile.mkdtemp(prefix=f".{_encode(name)}.import.", dir=os.path.dirname(session_root))
316
324
  backup_root = os.path.join(recovery_dir, "profile")
317
325
  try:
@@ -335,13 +343,6 @@ def import_bundle(base_dir, store, file_path,
335
343
  # bundle credential exists at the credential-backend level and has to be
336
344
  # resolved there. Read it strictly: refusing an unreadable keychain is
337
345
  # better than importing over a credential we could not preserve.
338
- bundle_carries_credential = provider == PROVIDER_CLAUDE and any(
339
- item["path"] == CLAUDE_CREDENTIALS_BUNDLE_PATH for item in decoded_profiles.get(name, [])
340
- )
341
- local_credential = None
342
- if bundle_carries_credential and old_record is not None:
343
- local_credential = read_keychain_credentials(auth_home)
344
-
345
346
  existing_state_before = None
346
347
  try:
347
348
  _ensure_private_dir(session_root)
@@ -78,7 +78,7 @@ def _fetch_latest_release(env=None):
78
78
  LATEST_RELEASE_URL,
79
79
  headers=headers,
80
80
  )
81
- with urllib.request.urlopen(request, timeout=5) as response:
81
+ with urllib.request.urlopen(request, timeout=2) as response:
82
82
  payload = json.loads(response.read().decode("utf-8"))
83
83
  return {
84
84
  "latest_version": str(payload.get("tag_name") or "").lstrip("v"),
@@ -185,6 +185,12 @@ def check_for_update(base_dir, current_version, env=None, now_fn=None):
185
185
 
186
186
  latest = fetch_latest_release(env=env)
187
187
  if not latest:
188
+ # A failure is cached like a success, keeping the last known version:
189
+ # otherwise a slow network makes every cdx command wait on GitHub again.
190
+ try:
191
+ _write_cache(path, {**cached, "checked_at": now_ts})
192
+ except OSError:
193
+ pass
188
194
  return None
189
195
 
190
196
  payload = {