cdx-manager 0.19.3 → 0.20.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.
- package/README.md +46 -4
- package/VERSION +1 -1
- package/changelogs/CHANGELOGS_0_20_0.md +71 -0
- package/changelogs/CHANGELOGS_0_20_1.md +6 -0
- package/checksums/release-archives.json +22 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/claude_usage.py +33 -0
- package/src/commands/launch.py +65 -11
- package/src/commands/status.py +131 -12
- 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/interactive_usage.py
CHANGED
|
@@ -1,29 +1,172 @@
|
|
|
1
|
-
"""Best-effort usage readers for interactive provider sessions.
|
|
1
|
+
"""Best-effort usage readers for interactive provider sessions.
|
|
2
|
+
|
|
3
|
+
Three providers report usage and one does not:
|
|
4
|
+
|
|
5
|
+
- Claude and Codex write their own JSONL transcripts, resolved by the
|
|
6
|
+
conversation id cdx already holds.
|
|
7
|
+
- Ollama writes nothing. Its counts exist only in `--verbose` terminal output,
|
|
8
|
+
so they are parsed out of the PTY capture cdx takes for every launch.
|
|
9
|
+
- Antigravity has no retrievable source. Its conversation history is
|
|
10
|
+
schema-less binary protobuf under `~/.gemini/antigravity-cli/conversations/`
|
|
11
|
+
with no `.proto` available to decode field numbers into token counts, and its
|
|
12
|
+
logs are plain glog with no usage-bearing line at any verbosity `agy --help`
|
|
13
|
+
exposes. This was investigated and is a dead end, not an oversight -- do not
|
|
14
|
+
spend the afternoon rediscovering it.
|
|
15
|
+
"""
|
|
2
16
|
|
|
3
17
|
import json
|
|
4
18
|
import os
|
|
19
|
+
import re
|
|
5
20
|
import time
|
|
6
21
|
|
|
22
|
+
from .run_usage import claude_usage_dedup_key, normalize_usage
|
|
23
|
+
from .status_source import _normalize_terminal_transcript
|
|
24
|
+
|
|
7
25
|
MAX_TRANSCRIPT_BYTES = 16 * 1024 * 1024
|
|
8
26
|
MAX_TRANSCRIPT_CANDIDATES = 1000
|
|
9
27
|
MAX_TRANSCRIPT_SCAN_SECONDS = 1
|
|
10
28
|
|
|
11
|
-
|
|
12
|
-
|
|
29
|
+
#: Codex repeats the conversation id at the end of each rollout filename.
|
|
30
|
+
_ROLLOUT_NAME = re.compile(r"^rollout-.*-([0-9a-fA-F-]{36})\.jsonl$")
|
|
31
|
+
|
|
32
|
+
#: Ollama's `--verbose` block, one per response. Captured from a real
|
|
33
|
+
#: `ollama run smollm2:135m --verbose` on 0.32.11:
|
|
34
|
+
#:
|
|
35
|
+
#: total duration: 1.159523666s
|
|
36
|
+
#: prompt eval count: 32 token(s)
|
|
37
|
+
#: eval count: 58 token(s)
|
|
38
|
+
#:
|
|
39
|
+
#: `prompt eval count` is the prompt actually evaluated for that response and
|
|
40
|
+
#: `eval count` what it generated, so a multi-turn session contributes one
|
|
41
|
+
#: block per turn and they sum. Ollama reports no cache and no reasoning: it is
|
|
42
|
+
#: a local runner with no prompt cache to read from, so those fields stay
|
|
43
|
+
#: absent rather than zero.
|
|
44
|
+
_OLLAMA_PROMPT_TOKENS = re.compile(r"^\s*prompt eval count:\s*(\d+)\s*token", re.M)
|
|
45
|
+
_OLLAMA_EVAL_TOKENS = re.compile(r"^\s*eval count:\s*(\d+)\s*token", re.M)
|
|
46
|
+
|
|
47
|
+
#: How the transcript that produced a usage record was found. An id match is
|
|
48
|
+
#: the session's own transcript by construction; a recency match is a guess
|
|
49
|
+
#: that happened to be the newest file, and must not read as the same thing.
|
|
50
|
+
MATCH_CONVERSATION_ID = "conversation_id"
|
|
51
|
+
MATCH_RECENCY = "recency"
|
|
52
|
+
#: The run's own terminal capture -- no ambiguity to resolve.
|
|
53
|
+
MATCH_RUN_TRANSCRIPT = "run_transcript"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def extract_interactive_usage(provider, auth_home, started_at=None, conversation_id=None,
|
|
57
|
+
terminal_transcript=None):
|
|
58
|
+
"""Return this session's provider-native usage, and how it was found.
|
|
13
59
|
|
|
14
60
|
Provider transcripts are not stable public APIs. A missing or changed
|
|
15
61
|
record is therefore ordinary absence, never a launch failure.
|
|
62
|
+
|
|
63
|
+
Resolution is by conversation id whenever the session has one: cdx mints
|
|
64
|
+
Claude's itself and passes it as `--session-id`, and observes Codex's back
|
|
65
|
+
after a run, so the session's own transcript is addressable by name. The
|
|
66
|
+
mtime scan that used to decide this returned whichever file in the whole
|
|
67
|
+
provider home was touched most recently, which is why sessions reported
|
|
68
|
+
three runs and eighteen output tokens -- they were being billed for an
|
|
69
|
+
unrelated file.
|
|
70
|
+
|
|
71
|
+
A session with an id whose transcript cannot be found reports absence
|
|
72
|
+
rather than falling back to that scan: a wrong attribution is worse than a
|
|
73
|
+
missing one, and it is the failure this replaces.
|
|
16
74
|
"""
|
|
17
|
-
path =
|
|
75
|
+
path, match = resolve_transcript(
|
|
76
|
+
provider, auth_home, started_at, conversation_id, terminal_transcript)
|
|
18
77
|
if not path:
|
|
19
|
-
return None, None
|
|
78
|
+
return None, None, None, None
|
|
20
79
|
try:
|
|
21
80
|
if os.path.getsize(path) > MAX_TRANSCRIPT_BYTES:
|
|
22
|
-
return None, path
|
|
81
|
+
return None, path, match, None
|
|
23
82
|
with open(path, encoding="utf-8", errors="replace") as handle:
|
|
24
|
-
|
|
83
|
+
usage, model = _READERS.get(provider, _claude_usage)(handle)
|
|
84
|
+
return usage, path, match, model
|
|
25
85
|
except OSError:
|
|
26
|
-
return None, path
|
|
86
|
+
return None, path, match, None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def resolve_transcript(provider, auth_home, started_at=None, conversation_id=None,
|
|
90
|
+
terminal_transcript=None):
|
|
91
|
+
"""The transcript belonging to this session, and how confidently."""
|
|
92
|
+
if provider == "ollama":
|
|
93
|
+
# No provider-native transcript exists to resolve. The run's own PTY
|
|
94
|
+
# capture is the only record, and the caller already knows its path --
|
|
95
|
+
# which makes this the most certain attribution of the three, not the
|
|
96
|
+
# least: there is no file to pick wrongly.
|
|
97
|
+
return (terminal_transcript, MATCH_RUN_TRANSCRIPT) if terminal_transcript else (None, None)
|
|
98
|
+
if not auth_home:
|
|
99
|
+
return None, None
|
|
100
|
+
if conversation_id:
|
|
101
|
+
path = _transcript_for_conversation(provider, auth_home, conversation_id)
|
|
102
|
+
if _covers_run(path, started_at) or provider != "codex":
|
|
103
|
+
return path, MATCH_CONVERSATION_ID
|
|
104
|
+
# Codex mints its conversation id itself and cdx can only read it back
|
|
105
|
+
# *after* the run, so at this point the session still names the
|
|
106
|
+
# previous conversation. Its rollout is untouched by this run, and
|
|
107
|
+
# differencing it yields a zero -- which is how a real codex turn came
|
|
108
|
+
# to report no usage at all.
|
|
109
|
+
#
|
|
110
|
+
# Falling back to recency is safe here in a way it was not for Claude:
|
|
111
|
+
# a Codex rollout lives under the session's own auth home, so the
|
|
112
|
+
# newest one touched during this run is this session's current
|
|
113
|
+
# conversation, not somebody else's file.
|
|
114
|
+
path = _latest_transcript(provider, auth_home, started_at)
|
|
115
|
+
return path, (MATCH_RECENCY if path else None)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _covers_run(path, started_at):
|
|
119
|
+
"""Whether this transcript was written during the run, so it can hold it."""
|
|
120
|
+
cutoff = _timestamp(started_at)
|
|
121
|
+
if not path:
|
|
122
|
+
return False
|
|
123
|
+
if cutoff is None:
|
|
124
|
+
return True
|
|
125
|
+
try:
|
|
126
|
+
return os.path.getmtime(path) >= cutoff
|
|
127
|
+
except OSError:
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def conversation_transcript(provider, auth_home, conversation_id):
|
|
132
|
+
"""The transcript file for this exact conversation, or None if it has none.
|
|
133
|
+
|
|
134
|
+
Answers "does this conversation actually exist" without the fallbacks
|
|
135
|
+
`resolve_transcript` applies: a caller asking whether a resume will work
|
|
136
|
+
must not be told yes because some *other* conversation was found.
|
|
137
|
+
"""
|
|
138
|
+
if not auth_home or not conversation_id:
|
|
139
|
+
return None
|
|
140
|
+
return _transcript_for_conversation(provider, auth_home, conversation_id)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _transcript_for_conversation(provider, auth_home, conversation_id):
|
|
144
|
+
if provider == "codex":
|
|
145
|
+
return _codex_rollout_for(auth_home, conversation_id)
|
|
146
|
+
# Reuse, not reimplement: the background path already resolved a Claude
|
|
147
|
+
# transcript by session id, and having two rules for the same lookup is
|
|
148
|
+
# how they drift apart.
|
|
149
|
+
from .provider_background import find_session_transcript
|
|
150
|
+
|
|
151
|
+
return find_session_transcript(auth_home, conversation_id)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _codex_rollout_for(auth_home, conversation_id):
|
|
155
|
+
"""Codex repeats its conversation id in the rollout filename."""
|
|
156
|
+
root = os.path.join(auth_home, "sessions")
|
|
157
|
+
if not os.path.isdir(root):
|
|
158
|
+
return None
|
|
159
|
+
for directory, _subdirs, names in os.walk(root):
|
|
160
|
+
for name in names:
|
|
161
|
+
match = _ROLLOUT_NAME.match(name)
|
|
162
|
+
if match and match.group(1).lower() == str(conversation_id).lower():
|
|
163
|
+
return os.path.join(directory, name)
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
#: Which reader parses which provider's transcript. Claude is the default
|
|
168
|
+
#: because its shape is what an unknown provider is most likely to resemble.
|
|
169
|
+
_READERS = {}
|
|
27
170
|
|
|
28
171
|
|
|
29
172
|
def _latest_transcript(provider, auth_home, started_at):
|
|
@@ -37,7 +180,10 @@ def _latest_transcript(provider, auth_home, started_at):
|
|
|
37
180
|
deadline = time.monotonic() + MAX_TRANSCRIPT_SCAN_SECONDS
|
|
38
181
|
for directory, subdirs, names in os.walk(root):
|
|
39
182
|
if time.monotonic() >= deadline:
|
|
40
|
-
|
|
183
|
+
# An inconclusive scan is absence, not "whichever candidate we had
|
|
184
|
+
# reached". Returning a partial answer here is what made a wrong
|
|
185
|
+
# attribution indistinguishable from a real measurement.
|
|
186
|
+
return None
|
|
41
187
|
if provider == "claude":
|
|
42
188
|
subdirs[:] = [name for name in subdirs if name != "subagents"]
|
|
43
189
|
for name in names:
|
|
@@ -51,10 +197,14 @@ def _latest_transcript(provider, auth_home, started_at):
|
|
|
51
197
|
if cutoff is None or modified >= cutoff:
|
|
52
198
|
candidates.append((modified, path))
|
|
53
199
|
if len(candidates) > MAX_TRANSCRIPT_CANDIDATES:
|
|
54
|
-
return
|
|
200
|
+
return None
|
|
55
201
|
return max(candidates, default=(None, None))[1]
|
|
56
202
|
|
|
57
203
|
|
|
204
|
+
_READERS.update({"codex": lambda handle: _codex_usage(handle),
|
|
205
|
+
"ollama": lambda handle: _ollama_usage(handle)})
|
|
206
|
+
|
|
207
|
+
|
|
58
208
|
def _timestamp(value):
|
|
59
209
|
if not value:
|
|
60
210
|
return None
|
|
@@ -65,56 +215,96 @@ def _timestamp(value):
|
|
|
65
215
|
return None
|
|
66
216
|
|
|
67
217
|
|
|
218
|
+
def _ollama_usage(handle):
|
|
219
|
+
"""Token counts parsed out of ollama's `--verbose` blocks.
|
|
220
|
+
|
|
221
|
+
Ollama has no provider-native transcript, so the only place these numbers
|
|
222
|
+
exist is the terminal, and the only copy of the terminal is the PTY capture
|
|
223
|
+
cdx already takes through `script`. That capture is not clean text -- a
|
|
224
|
+
real one carries cursor show/hide sequences between individual words and
|
|
225
|
+
carriage returns overwriting lines in place -- so it is normalized with the
|
|
226
|
+
same helper the handoff reader uses before any pattern is applied.
|
|
227
|
+
|
|
228
|
+
Returns no model: ollama runs locally and its models are not in any price
|
|
229
|
+
table, so naming one would only invite pricing something that is free.
|
|
230
|
+
"""
|
|
231
|
+
text = _normalize_terminal_transcript(handle.read())
|
|
232
|
+
prompts = [int(value) for value in _OLLAMA_PROMPT_TOKENS.findall(text)]
|
|
233
|
+
evals = [int(value) for value in _OLLAMA_EVAL_TOKENS.findall(text)]
|
|
234
|
+
if not prompts and not evals:
|
|
235
|
+
return None, None
|
|
236
|
+
return normalize_usage(
|
|
237
|
+
input_tokens=sum(prompts) if prompts else None,
|
|
238
|
+
output_tokens=sum(evals) if evals else None,
|
|
239
|
+
), None
|
|
240
|
+
|
|
241
|
+
|
|
68
242
|
def _codex_usage(handle):
|
|
243
|
+
"""The conversation's cumulative usage, and the model serving it latest."""
|
|
69
244
|
latest = None
|
|
245
|
+
model = None
|
|
70
246
|
for line in handle:
|
|
71
247
|
try:
|
|
72
248
|
record = json.loads(line)
|
|
73
249
|
payload = record.get("payload") or {}
|
|
250
|
+
if payload.get("model"):
|
|
251
|
+
model = payload["model"]
|
|
74
252
|
usage = ((payload.get("info") or {}).get("total_token_usage") or {}) if payload.get("type") == "token_count" else {}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
253
|
+
if not isinstance(usage, dict) or not usage:
|
|
254
|
+
continue
|
|
255
|
+
# Codex counts cached tokens inside `input_tokens`; reduce to the
|
|
256
|
+
# uncached remainder so the field means what run_usage says it
|
|
257
|
+
# means. Same subset check as the headless reader: a cached count
|
|
258
|
+
# larger than the input count is not a subset, so that record is
|
|
259
|
+
# left alone rather than forced to zero.
|
|
260
|
+
reported_input = _number(usage.get("input_tokens"))
|
|
261
|
+
cache_read = _number(usage.get("cached_input_tokens"))
|
|
262
|
+
latest = normalize_usage(
|
|
263
|
+
input_tokens=reported_input - cache_read if cache_read <= reported_input else reported_input,
|
|
264
|
+
cache_read_tokens=cache_read,
|
|
265
|
+
output_tokens=_number(usage.get("output_tokens")),
|
|
266
|
+
reasoning_tokens=_number(usage.get("reasoning_output_tokens")) or None,
|
|
267
|
+
)
|
|
78
268
|
except (TypeError, ValueError):
|
|
79
269
|
continue
|
|
80
|
-
return latest
|
|
270
|
+
return latest, model
|
|
81
271
|
|
|
82
272
|
|
|
83
273
|
def _claude_usage(handle):
|
|
84
|
-
|
|
274
|
+
"""Sum Claude's per-message usage across the transcript.
|
|
275
|
+
|
|
276
|
+
Cache creation and cache read are kept apart rather than summed on the way
|
|
277
|
+
in: they cost 1.25x and 0.1x of uncached input, so fusing them here would
|
|
278
|
+
destroy the only information a cost-aware figure needs. Claude reports no
|
|
279
|
+
separate reasoning count -- thinking is billed as output and is already
|
|
280
|
+
inside `output_tokens` -- so that field stays absent by design.
|
|
281
|
+
"""
|
|
282
|
+
totals = {"input_tokens": 0, "cache_creation_tokens": 0, "cache_read_tokens": 0, "output_tokens": 0}
|
|
85
283
|
seen = set()
|
|
284
|
+
model = None
|
|
86
285
|
for line in handle:
|
|
87
286
|
try:
|
|
88
287
|
record = json.loads(line)
|
|
89
288
|
message = record.get("message") or {}
|
|
90
289
|
usage = message.get("usage") or {}
|
|
91
|
-
|
|
92
|
-
if record.get("type") != "assistant" or not usage or (identifier and identifier in seen):
|
|
290
|
+
if record.get("type") != "assistant" or not usage:
|
|
93
291
|
continue
|
|
94
|
-
|
|
292
|
+
identifier = claude_usage_dedup_key(record)
|
|
293
|
+
if identifier is not None:
|
|
294
|
+
if identifier in seen:
|
|
295
|
+
continue
|
|
95
296
|
seen.add(identifier)
|
|
96
297
|
totals["input_tokens"] += _number(usage.get("input_tokens"))
|
|
97
|
-
totals["
|
|
298
|
+
totals["cache_creation_tokens"] += _number(usage.get("cache_creation_input_tokens"))
|
|
299
|
+
totals["cache_read_tokens"] += _number(usage.get("cache_read_input_tokens"))
|
|
98
300
|
totals["output_tokens"] += _number(usage.get("output_tokens"))
|
|
301
|
+
if message.get("model"):
|
|
302
|
+
model = message["model"]
|
|
99
303
|
except (TypeError, ValueError):
|
|
100
304
|
continue
|
|
101
305
|
if not seen and not any(totals.values()):
|
|
102
|
-
return None
|
|
103
|
-
totals
|
|
104
|
-
totals["total_tokens"] = totals["input_tokens"] + totals["output_tokens"]
|
|
105
|
-
return totals
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def _normalize(usage, cached_key, reasoning_key):
|
|
109
|
-
if not isinstance(usage, dict) or not usage:
|
|
110
|
-
return None
|
|
111
|
-
return {
|
|
112
|
-
"input_tokens": _number(usage.get("input_tokens")),
|
|
113
|
-
"cached_input_tokens": _number(usage.get(cached_key)),
|
|
114
|
-
"output_tokens": _number(usage.get("output_tokens")),
|
|
115
|
-
"reasoning_tokens": _number(usage.get(reasoning_key)) or None,
|
|
116
|
-
"total_tokens": _number(usage.get("total_tokens")) or None,
|
|
117
|
-
}
|
|
306
|
+
return None, model
|
|
307
|
+
return normalize_usage(**totals), model
|
|
118
308
|
|
|
119
309
|
|
|
120
310
|
def _number(value):
|
|
@@ -122,3 +312,72 @@ def _number(value):
|
|
|
122
312
|
return max(0, int(value or 0))
|
|
123
313
|
except (TypeError, ValueError):
|
|
124
314
|
return 0
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
#: The quantities a provider actually reports. The other two fields of a usage
|
|
318
|
+
#: record are derived by `normalize_usage` and must never be differenced.
|
|
319
|
+
MEASURED_KEYS = (
|
|
320
|
+
"input_tokens",
|
|
321
|
+
"cache_creation_tokens",
|
|
322
|
+
"cache_read_tokens",
|
|
323
|
+
"output_tokens",
|
|
324
|
+
"reasoning_tokens",
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def usage_delta(cumulative, previous):
|
|
329
|
+
"""What this run added to a transcript that already held `previous`.
|
|
330
|
+
|
|
331
|
+
Both readers return a *cumulative* figure: Claude's is the sum over the
|
|
332
|
+
whole transcript file, Codex's is its own running total for the
|
|
333
|
+
conversation. Storing that on every run and summing across runs billed a
|
|
334
|
+
resumed session for its entire history once per resume -- one observed
|
|
335
|
+
session's cache read went from 2.6M to 7.6M on a single additional launch.
|
|
336
|
+
|
|
337
|
+
Returns None when the difference cannot be trusted: a transcript that
|
|
338
|
+
shrank, rotated, or was replaced produces a negative component, and there
|
|
339
|
+
is no arithmetic that recovers the truth from that. Absence is recoverable
|
|
340
|
+
on the next run; a fabricated number is not.
|
|
341
|
+
"""
|
|
342
|
+
if not isinstance(cumulative, dict):
|
|
343
|
+
return None
|
|
344
|
+
if not isinstance(previous, dict):
|
|
345
|
+
return None
|
|
346
|
+
parts = {}
|
|
347
|
+
for key in MEASURED_KEYS:
|
|
348
|
+
now = cumulative.get(key)
|
|
349
|
+
before = previous.get(key)
|
|
350
|
+
if now is None:
|
|
351
|
+
parts[key] = None
|
|
352
|
+
continue
|
|
353
|
+
difference = now - (before or 0)
|
|
354
|
+
if difference < 0:
|
|
355
|
+
return None
|
|
356
|
+
parts[key] = difference
|
|
357
|
+
return normalize_usage(**parts)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def transcript_predates_run(path, started_at):
|
|
361
|
+
"""Whether this transcript already existed before the run began.
|
|
362
|
+
|
|
363
|
+
Decides what the *first* run against a given transcript may record. A
|
|
364
|
+
transcript created during the run is wholly this run's work, so its total
|
|
365
|
+
is the run's usage. One that predates the run holds history cdx never
|
|
366
|
+
measured, and attributing all of it to this run would commit the very
|
|
367
|
+
over-count the delta exists to prevent -- so that run reports absence and
|
|
368
|
+
only leaves the baseline behind for its successor.
|
|
369
|
+
"""
|
|
370
|
+
cutoff = _timestamp(started_at)
|
|
371
|
+
if cutoff is None or not path:
|
|
372
|
+
return False
|
|
373
|
+
try:
|
|
374
|
+
stat = os.stat(path)
|
|
375
|
+
except OSError:
|
|
376
|
+
return False
|
|
377
|
+
# st_birthtime where the platform records it (macOS, some BSDs); st_ctime
|
|
378
|
+
# elsewhere, where it means metadata change and is an upper bound on
|
|
379
|
+
# creation -- which errs toward "predates", the conservative answer.
|
|
380
|
+
created = getattr(stat, "st_birthtime", None)
|
|
381
|
+
if created is None:
|
|
382
|
+
created = stat.st_ctime
|
|
383
|
+
return created < cutoff
|
|
@@ -24,6 +24,7 @@ import re
|
|
|
24
24
|
import subprocess
|
|
25
25
|
|
|
26
26
|
from .config import PROVIDER_CLAUDE
|
|
27
|
+
from .run_usage import claude_usage_dedup_key, normalize_usage
|
|
27
28
|
|
|
28
29
|
BACKGROUND_PATH_PROVIDER = "provider_native"
|
|
29
30
|
BACKGROUND_PATH_CDX = "cdx_detached"
|
|
@@ -221,7 +222,10 @@ def read_transcript_outcome(path):
|
|
|
221
222
|
outcome = {"result": None, "usage": None}
|
|
222
223
|
if not path:
|
|
223
224
|
return outcome
|
|
224
|
-
totals = {"input_tokens": 0, "
|
|
225
|
+
totals = {"input_tokens": 0, "cache_creation_tokens": 0, "cache_read_tokens": 0, "output_tokens": 0}
|
|
226
|
+
# This path had no de-duplication at all, so a transcript that records the
|
|
227
|
+
# same billed response more than once inflated the total outright.
|
|
228
|
+
counted = set()
|
|
225
229
|
seen = False
|
|
226
230
|
try:
|
|
227
231
|
with open(path, encoding="utf-8", errors="replace") as handle:
|
|
@@ -247,17 +251,22 @@ def read_transcript_outcome(path):
|
|
|
247
251
|
if text:
|
|
248
252
|
outcome["result"] = text
|
|
249
253
|
usage = message.get("usage")
|
|
250
|
-
if isinstance(usage, dict)
|
|
254
|
+
key = claude_usage_dedup_key(record) if isinstance(usage, dict) else None
|
|
255
|
+
if isinstance(usage, dict) and (key is None or key not in counted):
|
|
256
|
+
if key is not None:
|
|
257
|
+
counted.add(key)
|
|
258
|
+
# Same split as the interactive reader, and for the same
|
|
259
|
+
# reason: this path used to fold the cache into input and
|
|
260
|
+
# emit no cached field at all, so a detached run and an
|
|
261
|
+
# interactive one filled the same column under opposite
|
|
262
|
+
# definitions.
|
|
251
263
|
seen = True
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
totals["
|
|
256
|
-
totals["output_tokens"] += out
|
|
257
|
-
totals["total_tokens"] += inp + out
|
|
264
|
+
totals["input_tokens"] += usage.get("input_tokens") or 0
|
|
265
|
+
totals["cache_creation_tokens"] += usage.get("cache_creation_input_tokens") or 0
|
|
266
|
+
totals["cache_read_tokens"] += usage.get("cache_read_input_tokens") or 0
|
|
267
|
+
totals["output_tokens"] += usage.get("output_tokens") or 0
|
|
258
268
|
except OSError:
|
|
259
269
|
return outcome
|
|
260
270
|
if seen:
|
|
261
|
-
|
|
262
|
-
outcome["usage"] = totals
|
|
271
|
+
outcome["usage"] = normalize_usage(**totals)
|
|
263
272
|
return outcome
|
package/src/provider_runtime.py
CHANGED
|
@@ -54,6 +54,21 @@ LOGICS_PROMPT = (
|
|
|
54
54
|
"`logics-manager flow ...` for request/backlog/task lifecycle changes; and `logics-manager mcp ...` "
|
|
55
55
|
"when an MCP surface is the right fit."
|
|
56
56
|
)
|
|
57
|
+
#: Turns on ollama's per-response timing block, which carries the token counts.
|
|
58
|
+
#: Ollama writes no provider-native transcript, so without this there is
|
|
59
|
+
#: nothing anywhere to read and `cdx stats` shows a permanent zero.
|
|
60
|
+
OLLAMA_USAGE_FLAG = "--verbose"
|
|
61
|
+
|
|
62
|
+
#: Flags cdx passes on its own initiative -- not asked for by the user and not
|
|
63
|
+
#: derived from a permission. Declared here rather than inline so the
|
|
64
|
+
#: provider-flag health check can verify they exist in the installed CLI: the
|
|
65
|
+
#: generalized form of GitHub issue #8, where `--experimental-yolo` was mapped
|
|
66
|
+
#: for ollama and that CLI never had it. A flag cdx invents is exactly as
|
|
67
|
+
#: capable of not existing as a flag it maps.
|
|
68
|
+
LAUNCH_FEATURE_ARGS = {
|
|
69
|
+
PROVIDER_OLLAMA: [OLLAMA_USAGE_FLAG],
|
|
70
|
+
}
|
|
71
|
+
|
|
57
72
|
LAUNCH_PERMISSION_ARGS = {
|
|
58
73
|
PROVIDER_CLAUDE: {
|
|
59
74
|
"review": ["--permission-mode", "plan"],
|
|
@@ -439,6 +454,7 @@ def _launch_config_args(session):
|
|
|
439
454
|
# Both read their mapping from the shared table rather than inline
|
|
440
455
|
# branches, so the provider-flag health check can verify what they emit
|
|
441
456
|
# instead of a hand-copied restatement of it.
|
|
457
|
+
args += LAUNCH_FEATURE_ARGS.get(provider, [])
|
|
442
458
|
if permission:
|
|
443
459
|
args += LAUNCH_PERMISSION_ARGS[provider].get(permission, [])
|
|
444
460
|
return args
|
|
@@ -592,6 +608,11 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
|
|
|
592
608
|
launch = session.get("launch") or {}
|
|
593
609
|
model = launch.get("model") or session["name"]
|
|
594
610
|
ollama_env = {**env, "OLLAMA_NOHISTORY": "1"}
|
|
611
|
+
# `--verbose` arrives through _launch_config_args from
|
|
612
|
+
# LAUNCH_FEATURE_ARGS, so the health check and the launch read one
|
|
613
|
+
# table. Placement between the model and the prompt is accepted --
|
|
614
|
+
# verified against ollama 0.32.11, which parsed the arguments and
|
|
615
|
+
# failed only on the model pull.
|
|
595
616
|
args = ["run", model] + _launch_config_args(session)
|
|
596
617
|
if initial_prompt:
|
|
597
618
|
args.append(initial_prompt)
|
|
@@ -621,18 +642,44 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
|
|
|
621
642
|
}, capture_transcript=capture_transcript, env=env)
|
|
622
643
|
|
|
623
644
|
|
|
645
|
+
def _conversation_is_resumable(session, provider, conversation_id):
|
|
646
|
+
"""Whether the named conversation exists on disk and can be resumed.
|
|
647
|
+
|
|
648
|
+
A lookup failure reads as "not found" rather than raising: this decides
|
|
649
|
+
which of two working strategies to use, so it must never be able to fail a
|
|
650
|
+
command outright.
|
|
651
|
+
"""
|
|
652
|
+
if not conversation_id:
|
|
653
|
+
return False
|
|
654
|
+
try:
|
|
655
|
+
from .interactive_usage import conversation_transcript
|
|
656
|
+
|
|
657
|
+
return bool(conversation_transcript(provider, _get_auth_home(session), conversation_id))
|
|
658
|
+
except (OSError, ImportError):
|
|
659
|
+
return False
|
|
660
|
+
|
|
661
|
+
|
|
624
662
|
def _redacted_resume_command_preview(session, cwd=None):
|
|
625
663
|
capability = get_resume_capability(session, cwd=cwd)
|
|
626
664
|
return capability.get("command_preview")
|
|
627
665
|
|
|
628
666
|
|
|
629
667
|
def get_resume_capability(session, cwd=None):
|
|
668
|
+
"""What resuming this session would actually do.
|
|
669
|
+
|
|
670
|
+
The stored conversation id is a claim, not a fact: it survives a launch the
|
|
671
|
+
provider never persisted, and cdx then promised a resume that failed with
|
|
672
|
+
the provider's own raw error. The conversation is therefore looked up
|
|
673
|
+
before it is named, and a missing one degrades to the fallback each
|
|
674
|
+
provider already has rather than being offered as supported.
|
|
675
|
+
"""
|
|
630
676
|
provider = session.get("provider")
|
|
631
677
|
cwd = cwd or os.getcwd()
|
|
632
678
|
conversation = session.get("conversation") or {}
|
|
633
679
|
conversation_id = _conversation_id(session)
|
|
680
|
+
known = _conversation_is_resumable(session, provider, conversation_id)
|
|
634
681
|
if provider == PROVIDER_CODEX:
|
|
635
|
-
if
|
|
682
|
+
if known:
|
|
636
683
|
return {
|
|
637
684
|
"resumable": True,
|
|
638
685
|
"provider": provider,
|
|
@@ -650,11 +697,11 @@ def get_resume_capability(session, cwd=None):
|
|
|
650
697
|
"provenance": None,
|
|
651
698
|
# Codex only reveals a conversation id once a run has written its
|
|
652
699
|
# rollout, so a session that has never run legitimately has none.
|
|
653
|
-
"reason": "no_recorded_conversation",
|
|
700
|
+
"reason": "conversation_not_found" if conversation_id else "no_recorded_conversation",
|
|
654
701
|
"command_preview": ["codex", "resume", "--last", "--cd", cwd],
|
|
655
702
|
}
|
|
656
703
|
if provider == PROVIDER_CLAUDE:
|
|
657
|
-
if
|
|
704
|
+
if known:
|
|
658
705
|
return {
|
|
659
706
|
"resumable": True,
|
|
660
707
|
"provider": provider,
|
|
@@ -670,7 +717,7 @@ def get_resume_capability(session, cwd=None):
|
|
|
670
717
|
"strategy": "provider_continue",
|
|
671
718
|
"identity": None,
|
|
672
719
|
"provenance": None,
|
|
673
|
-
"reason": "no_recorded_conversation",
|
|
720
|
+
"reason": "conversation_not_found" if conversation_id else "no_recorded_conversation",
|
|
674
721
|
"command_preview": ["claude", "--continue"],
|
|
675
722
|
}
|
|
676
723
|
return {
|
|
@@ -697,9 +744,15 @@ def _build_resume_spec(session, cwd=None, env_override=None, capture_transcript=
|
|
|
697
744
|
launch = session.get("launch") or {}
|
|
698
745
|
# Resuming by id targets the conversation this session actually last
|
|
699
746
|
# opened; --continue only means "the most recent one in this directory",
|
|
700
|
-
# which another session or another cwd can silently change.
|
|
701
|
-
|
|
702
|
-
|
|
747
|
+
# which another session or another cwd can silently change. So the id
|
|
748
|
+
# is preferred -- but only when the capability confirmed the
|
|
749
|
+
# conversation exists, since naming one the provider never wrote just
|
|
750
|
+
# trades a vaguer resume for a failed one.
|
|
751
|
+
args = (
|
|
752
|
+
["--resume", capability["identity"]]
|
|
753
|
+
if capability["strategy"] == "provider_conversation_id"
|
|
754
|
+
else ["--continue"]
|
|
755
|
+
)
|
|
703
756
|
args += ["--name", session["name"]]
|
|
704
757
|
if launch.get("model"):
|
|
705
758
|
args += ["--model", _claude_cli_model(launch["model"])]
|
|
@@ -723,8 +776,13 @@ def _build_resume_spec(session, cwd=None, env_override=None, capture_transcript=
|
|
|
723
776
|
}, capture_transcript=capture_transcript, env=env)
|
|
724
777
|
|
|
725
778
|
launch = session.get("launch") or {}
|
|
726
|
-
|
|
727
|
-
|
|
779
|
+
# Same rule as the Claude branch: name the conversation only when the
|
|
780
|
+
# capability confirmed its rollout exists, else fall back to --last.
|
|
781
|
+
args = (
|
|
782
|
+
["resume", capability["identity"]]
|
|
783
|
+
if capability["strategy"] == "provider_conversation_id"
|
|
784
|
+
else ["resume", "--last"]
|
|
785
|
+
)
|
|
728
786
|
args += ["--cd", cwd]
|
|
729
787
|
if launch.get("model"):
|
|
730
788
|
args += ["--model", launch["model"]]
|
package/src/repair.py
CHANGED
|
@@ -41,6 +41,16 @@ def repair_health(service, base_dir, env=None, dry_run=True, force=False):
|
|
|
41
41
|
detail,
|
|
42
42
|
"skipped",
|
|
43
43
|
))
|
|
44
|
+
usage = service["drop_unvouched_usage"](dry_run=dry_run)
|
|
45
|
+
if usage["dropped"]:
|
|
46
|
+
actions.append(_action(
|
|
47
|
+
"drop_unvouched_usage",
|
|
48
|
+
f"drop token usage from {usage['dropped']} launch record"
|
|
49
|
+
f"{'s' if usage['dropped'] != 1 else ''} written before the field "
|
|
50
|
+
f"definition existed (of {usage['scanned']} scanned)",
|
|
51
|
+
None,
|
|
52
|
+
None if dry_run else "applied",
|
|
53
|
+
))
|
|
44
54
|
return {
|
|
45
55
|
"dry_run": dry_run,
|
|
46
56
|
"force": force,
|
package/src/run_registry.py
CHANGED
|
@@ -7,6 +7,7 @@ from datetime import datetime, timezone
|
|
|
7
7
|
|
|
8
8
|
from .errors import CdxError
|
|
9
9
|
from .fs_utils import atomic_write
|
|
10
|
+
from .run_usage import coerce_usage
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
def utc_now_iso():
|
|
@@ -315,7 +316,10 @@ class RunRegistry:
|
|
|
315
316
|
"stderr_path": run_info.get("stderr_path"),
|
|
316
317
|
}
|
|
317
318
|
if final_payload:
|
|
318
|
-
|
|
319
|
+
# Normalize on the way in, not on the way out: this record is
|
|
320
|
+
# what `cdx run-status` and `cdx run-report` publish, and it
|
|
321
|
+
# used to store whichever shape the producing reader emitted.
|
|
322
|
+
run["usage"] = coerce_usage(final_payload.get("usage"))
|
|
319
323
|
run["final_payload"] = final_payload
|
|
320
324
|
if error:
|
|
321
325
|
run["error"] = error
|