cdx-manager 0.20.13 → 0.21.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 +28 -1151
- package/VERSION +1 -1
- package/changelogs/CHANGELOGS_0_20_14.md +34 -0
- package/changelogs/CHANGELOGS_0_21_0.md +17 -0
- package/checksums/release-archives.json +22 -0
- package/docs/assets/icon.png +0 -0
- package/docs/assets/list.png +0 -0
- package/docs/assets/next.png +0 -0
- package/docs/assets/stats.png +0 -0
- package/docs/assets/status.png +0 -0
- package/docs/cli.md +411 -0
- package/docs/features.md +102 -0
- package/docs/getting-started.md +183 -0
- package/docs/operations.md +161 -0
- package/docs/sessions-and-notifications.md +138 -0
- package/docs/technical-overview.md +92 -0
- package/docs/tray-local-testing.md +154 -0
- package/package.json +2 -1
- package/pyproject.toml +1 -1
- package/src/cli.py +1 -1
- package/src/cli_args.py +1 -1
- package/src/commands/launch.py +44 -9
- package/src/commands/runs.py +38 -16
- package/src/commands/status.py +24 -38
- package/src/context_store.py +2 -1
- package/src/handoff.py +36 -20
- package/src/provider_background.py +6 -1
- package/src/provider_runtime.py +1 -1
- package/src/run_registry.py +7 -7
- package/src/run_usage.py +82 -35
- package/src/session_backup.py +8 -7
package/src/handoff.py
CHANGED
|
@@ -11,7 +11,6 @@ from uuid import uuid4
|
|
|
11
11
|
from .context_store import get_context_path, read_context
|
|
12
12
|
from .errors import CdxError
|
|
13
13
|
from .fs_utils import atomic_write
|
|
14
|
-
from .interactive_usage import conversation_transcript
|
|
15
14
|
from .provider_runtime import _get_auth_home, _list_launch_transcript_paths
|
|
16
15
|
|
|
17
16
|
CANDIDATE_LIMIT = 20
|
|
@@ -25,13 +24,19 @@ class HandoffSourceError(CdxError):
|
|
|
25
24
|
|
|
26
25
|
def _native_paths(session):
|
|
27
26
|
home = _get_auth_home(session)
|
|
27
|
+
root = os.path.realpath(home)
|
|
28
|
+
def owned(path):
|
|
29
|
+
try:
|
|
30
|
+
return os.path.commonpath((root, os.path.realpath(path))) == root
|
|
31
|
+
except ValueError:
|
|
32
|
+
return False
|
|
28
33
|
if session['provider'] == 'codex':
|
|
29
|
-
paths = glob.glob(os.path.join(home, 'sessions', '**', '*.jsonl'), recursive=True)
|
|
30
|
-
return [p for p in paths if 'subagents' not in os.path.relpath(p, home).split(os.sep)]
|
|
34
|
+
paths = glob.glob(os.path.join(glob.escape(home), 'sessions', '**', '*.jsonl'), recursive=True)
|
|
35
|
+
return [p for p in paths if owned(p) and 'subagents' not in os.path.relpath(p, home).split(os.sep)]
|
|
31
36
|
if session['provider'] == 'claude':
|
|
32
37
|
return sorted(set(
|
|
33
|
-
p for
|
|
34
|
-
for p in glob.glob(os.path.join(home, *
|
|
38
|
+
p for layout in (('.claude', 'projects'), ('projects',))
|
|
39
|
+
for p in glob.glob(os.path.join(glob.escape(home), *layout, '*', '*.jsonl')) if owned(p)
|
|
35
40
|
))
|
|
36
41
|
return []
|
|
37
42
|
|
|
@@ -119,7 +124,7 @@ def source_candidates(session, workspace):
|
|
|
119
124
|
return sorted(candidates, key=lambda c: (c['last_event_at'] or '', c['path']), reverse=True)[:CANDIDATE_LIMIT]
|
|
120
125
|
|
|
121
126
|
|
|
122
|
-
def resolve_source(session, workspace, conversation_id=None, terminal_path=None):
|
|
127
|
+
def resolve_source(session, workspace, conversation_id=None, terminal_path=None, native_path=None):
|
|
123
128
|
workspace = os.path.realpath(workspace)
|
|
124
129
|
if terminal_path:
|
|
125
130
|
path = os.path.realpath(terminal_path)
|
|
@@ -150,26 +155,37 @@ def resolve_source(session, workspace, conversation_id=None, terminal_path=None)
|
|
|
150
155
|
reason = 'No recorded conversation identity.'
|
|
151
156
|
if session['provider'] not in ('codex', 'claude'):
|
|
152
157
|
raise HandoffSourceError('No supported native transcript for this provider. Select a known launch log with --source-transcript PATH for degraded recovery.')
|
|
158
|
+
paths = _native_paths(session)
|
|
159
|
+
if native_path:
|
|
160
|
+
path = os.path.realpath(native_path)
|
|
161
|
+
allowed = {os.path.realpath(p) for p in paths}
|
|
162
|
+
if path not in allowed:
|
|
163
|
+
raise HandoffSourceError('Native transcript must be a top-level source file in the source profile.')
|
|
164
|
+
try:
|
|
165
|
+
info = inspect_transcript(path, session['provider'])
|
|
166
|
+
except CdxError as error:
|
|
167
|
+
raise HandoffSourceError(str(error)) from error
|
|
168
|
+
if info['workspace'] != workspace:
|
|
169
|
+
raise HandoffSourceError('Native transcript workspace does not match the target workspace.')
|
|
170
|
+
info['provenance'] = 'explicit_native_path'
|
|
171
|
+
return info
|
|
153
172
|
if selected:
|
|
154
173
|
if not isinstance(selected, str) or not re.fullmatch(r'[A-Za-z0-9_-]+', selected):
|
|
155
174
|
raise HandoffSourceError('Invalid source conversation ID.')
|
|
156
|
-
path = conversation_transcript(session['provider'], _get_auth_home(session), selected)
|
|
157
|
-
# The native resolver may walk nested subagent directories; only top-level
|
|
158
|
-
# conversations are eligible for handoff, even when explicitly named.
|
|
159
|
-
paths = _native_paths(session)
|
|
160
|
-
if not path:
|
|
161
|
-
matches = [p for p in paths if os.path.basename(p) == selected + '.jsonl']
|
|
162
|
-
path = matches[0] if len(matches) == 1 else None
|
|
163
175
|
reason = f'Conversation {selected} is missing or is not a top-level source.'
|
|
164
|
-
|
|
176
|
+
matches = []
|
|
177
|
+
for path in paths:
|
|
165
178
|
try:
|
|
166
179
|
info = inspect_transcript(path, session['provider'])
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
180
|
+
except CdxError:
|
|
181
|
+
continue
|
|
182
|
+
if info['conversation_id'] == selected and info['workspace'] == workspace:
|
|
183
|
+
matches.append(info)
|
|
184
|
+
if len(matches) > 1:
|
|
185
|
+
raise HandoffSourceError(f'Conversation {selected} has multiple native transcripts; select --source-native-transcript PATH.', matches)
|
|
186
|
+
if matches:
|
|
187
|
+
matches[0]['provenance'] = 'explicit_identity' if conversation_id else 'recorded_identity'
|
|
188
|
+
return matches[0]
|
|
173
189
|
candidates = source_candidates(session, workspace)
|
|
174
190
|
raise HandoffSourceError(
|
|
175
191
|
reason + ' Select the intended ID with --source-conversation ID; no recency fallback is used.',
|
|
@@ -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
|
package/src/provider_runtime.py
CHANGED
|
@@ -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)
|
package/src/run_registry.py
CHANGED
|
@@ -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
|
|
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
|
|
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 = (
|
|
@@ -89,8 +92,8 @@ def normalize_usage(input_tokens=None, cache_creation_tokens=None, cache_read_to
|
|
|
89
92
|
|
|
90
93
|
#: What a cached token costs relative to one uncached input token.
|
|
91
94
|
#:
|
|
92
|
-
#:
|
|
93
|
-
#:
|
|
95
|
+
#: Defaults for rows without an explicit cache-read price. Newer models can
|
|
96
|
+
#: discount reads further; both stats and currency use the table's rate.
|
|
94
97
|
#: Anthropic's one-hour cache TTL costs 2x instead; nothing in a transcript
|
|
95
98
|
#: says which TTL was used, so long-TTL writes are under-weighted. Cache writes
|
|
96
99
|
#: are a small share of a cache-heavy session, so that error is bounded --
|
|
@@ -109,7 +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
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def cache_read_multiplier(model, prices=None):
|
|
119
|
+
"""This model's cache-read/input ratio, with the legacy fallback."""
|
|
120
|
+
entry = (prices if prices is not None else token_prices()[0]).get(model or "")
|
|
121
|
+
if not entry:
|
|
122
|
+
return CACHE_READ_MULTIPLIER
|
|
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
|
|
113
128
|
|
|
114
129
|
|
|
115
130
|
def weighted_usage(usage, model=None, prices=None):
|
|
@@ -120,9 +135,8 @@ def weighted_usage(usage, model=None, prices=None):
|
|
|
120
135
|
tokens -- so a raw total is very nearly a measure of cache reads alone.
|
|
121
136
|
That is not the ranking anyone reading `cdx stats` is looking for.
|
|
122
137
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
not, so they are constants.
|
|
138
|
+
Output and cache-read ratios come from the model that served the run.
|
|
139
|
+
Cache writes retain the five-minute TTL assumption.
|
|
126
140
|
|
|
127
141
|
`reasoning_tokens` is deliberately excluded: Codex reports it as a subset
|
|
128
142
|
of its output, and counting it would bill those tokens twice.
|
|
@@ -132,14 +146,16 @@ def weighted_usage(usage, model=None, prices=None):
|
|
|
132
146
|
weights = {
|
|
133
147
|
"input_tokens": 1.0,
|
|
134
148
|
"cache_creation_tokens": CACHE_WRITE_MULTIPLIER,
|
|
135
|
-
"cache_read_tokens":
|
|
149
|
+
"cache_read_tokens": cache_read_multiplier(model, prices),
|
|
136
150
|
"output_tokens": output_multiplier(model, prices),
|
|
137
151
|
}
|
|
138
|
-
present = [
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if
|
|
142
|
-
|
|
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)
|
|
143
159
|
if not present:
|
|
144
160
|
return None
|
|
145
161
|
return int(round(sum(present)))
|
|
@@ -160,12 +176,16 @@ def weighted_usage(usage, model=None, prices=None):
|
|
|
160
176
|
#: Anthropic and OpenAI figures come from the vendors' own references, checked
|
|
161
177
|
#: on the review date below.
|
|
162
178
|
#:
|
|
163
|
-
#: Not modelled: OpenAI's long-context tier, which
|
|
179
|
+
#: Not modelled: OpenAI's long-context tier, which increases the rates
|
|
164
180
|
#: above a 272K-token request. cdx records tokens per run, not per request, so
|
|
165
181
|
#: it cannot tell which requests crossed that line -- long-context Codex work
|
|
166
182
|
#: is therefore under-costed here, and knowingly so.
|
|
167
183
|
DEFAULT_TOKEN_PRICES = {
|
|
168
184
|
# Anthropic
|
|
185
|
+
"claude-fable-5-1": {"input": 10.0, "output": 50.0, "cache_read": 0.25},
|
|
186
|
+
"claude-mythos-5-1": {"input": 10.0, "output": 50.0, "cache_read": 0.25},
|
|
187
|
+
"claude-opus-5-5": {"input": 4.0, "output": 20.0, "cache_read": 0.2},
|
|
188
|
+
"claude-sonnet-5-5": {"input": 2.0, "output": 10.0},
|
|
169
189
|
"claude-fable-5": {"input": 10.0, "output": 50.0},
|
|
170
190
|
"claude-mythos-5": {"input": 10.0, "output": 50.0},
|
|
171
191
|
"claude-opus-5": {"input": 5.0, "output": 25.0},
|
|
@@ -180,6 +200,9 @@ DEFAULT_TOKEN_PRICES = {
|
|
|
180
200
|
"claude-haiku-4-5": {"input": 1.0, "output": 5.0},
|
|
181
201
|
"claude-haiku-4-5-20251001": {"input": 1.0, "output": 5.0},
|
|
182
202
|
# OpenAI
|
|
203
|
+
"gpt-6.1-sol": {"input": 2.0, "output": 10.0, "cache_read": 0.1},
|
|
204
|
+
"gpt-6-sol": {"input": 2.0, "output": 10.0},
|
|
205
|
+
"gpt-6-luna": {"input": 0.1, "output": 0.5},
|
|
183
206
|
"gpt-6-astra": {"input": 10.0, "output": 50.0},
|
|
184
207
|
"gpt-5.6": {"input": 4.0, "output": 20.0},
|
|
185
208
|
"gpt-5.6-sol": {"input": 4.0, "output": 20.0},
|
|
@@ -195,7 +218,7 @@ DEFAULT_TOKEN_PRICES = {
|
|
|
195
218
|
"gpt-5.4-nano-2026-03-17": {"input": 0.2, "output": 1.25},
|
|
196
219
|
"gpt-5.3-codex": {"input": 1.75, "output": 14.0},
|
|
197
220
|
}
|
|
198
|
-
TOKEN_PRICES_REVIEWED = "2026-
|
|
221
|
+
TOKEN_PRICES_REVIEWED = "2026-10-04"
|
|
199
222
|
#: How long a review stays fresh before the staleness test asks for another.
|
|
200
223
|
TOKEN_PRICES_MAX_AGE_DAYS = 90
|
|
201
224
|
TOKEN_PRICES_ENV = "CDX_TOKEN_PRICES"
|
|
@@ -210,15 +233,26 @@ def token_prices(env=None):
|
|
|
210
233
|
if raw:
|
|
211
234
|
try:
|
|
212
235
|
overrides = json.loads(raw)
|
|
213
|
-
except ValueError:
|
|
214
|
-
|
|
215
|
-
if isinstance(overrides, dict):
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
222
256
|
return dict(DEFAULT_TOKEN_PRICES), f"built-in, reviewed {TOKEN_PRICES_REVIEWED}"
|
|
223
257
|
|
|
224
258
|
|
|
@@ -229,10 +263,8 @@ def estimate_cost(usage, model, prices=None):
|
|
|
229
263
|
figure. That shortcut was only correct while every model shared one
|
|
230
264
|
output-to-input ratio, and they do not.
|
|
231
265
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
mid-run is priced at the newer one. Upgrade path if that matters: have the
|
|
235
|
-
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.
|
|
236
268
|
|
|
237
269
|
An unknown or absent model yields None. Pricing it at a default tier would
|
|
238
270
|
turn "cdx does not know" into a number someone might believe.
|
|
@@ -244,7 +276,7 @@ def estimate_cost(usage, model, prices=None):
|
|
|
244
276
|
return None
|
|
245
277
|
per_million = {
|
|
246
278
|
"input_tokens": rate["input"],
|
|
247
|
-
"cache_read_tokens": rate["input"] * CACHE_READ_MULTIPLIER,
|
|
279
|
+
"cache_read_tokens": rate.get("cache_read", rate["input"] * CACHE_READ_MULTIPLIER),
|
|
248
280
|
"cache_creation_tokens": rate["input"] * CACHE_WRITE_MULTIPLIER,
|
|
249
281
|
"output_tokens": rate["output"],
|
|
250
282
|
}
|
|
@@ -341,26 +373,41 @@ def _sum_present(*values):
|
|
|
341
373
|
|
|
342
374
|
|
|
343
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):
|
|
344
380
|
if not stdout_path or not provider:
|
|
345
|
-
return empty_usage()
|
|
381
|
+
return empty_usage(), None
|
|
346
382
|
if provider not in SUPPORTED_PROVIDERS:
|
|
347
|
-
return empty_usage()
|
|
383
|
+
return empty_usage(), None
|
|
348
384
|
try:
|
|
349
385
|
with open(stdout_path, encoding="utf-8", errors="replace") as handle:
|
|
350
386
|
text = handle.read()
|
|
351
387
|
except OSError:
|
|
352
|
-
return empty_usage()
|
|
388
|
+
return empty_usage(), None
|
|
353
389
|
if not text.strip():
|
|
354
|
-
return empty_usage()
|
|
390
|
+
return empty_usage(), None
|
|
355
391
|
|
|
356
392
|
records = _parse_json_records(text)
|
|
357
393
|
if not records:
|
|
358
|
-
return empty_usage()
|
|
394
|
+
return empty_usage(), None
|
|
359
395
|
|
|
360
396
|
usage = _extract_usage_from_records(records)
|
|
361
397
|
if not _has_usage(usage):
|
|
362
|
-
return empty_usage()
|
|
363
|
-
|
|
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
|
|
364
411
|
|
|
365
412
|
|
|
366
413
|
def _parse_json_records(text):
|
package/src/session_backup.py
CHANGED
|
@@ -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)
|