@arbiterforge/ca-pi 0.15.6 → 0.15.8
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 +1 -1
- package/package.json +1 -1
- package/plugins/ca-pi/CHANGELOG.md +16 -0
- package/plugins/ca-pi/hooks/_gitlib.py +8 -2
- package/plugins/ca-pi/hooks/_host.py +1 -1
- package/plugins/ca-pi/hooks/_ledgerlib.py +1022 -355
- package/plugins/ca-pi/hooks/_segmentslib.py +64 -4
- package/plugins/ca-pi/hooks/_subagentslib.py +30 -64
- package/plugins/ca-pi/hooks/_usagelib.py +484 -0
- package/plugins/ca-pi/hooks/hostapi.py +1 -1
- package/plugins/ca-pi/hooks/statusline.py +40 -24
- package/plugins/ca-pi/package.json +1 -1
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# codeArbiter — Claude transcript usage normalization, identity, reconciliation
|
|
3
|
+
# and pricing (spec: claude-statusline-accounting-integrity D-5..D-10, D-26, D-27).
|
|
4
|
+
#
|
|
5
|
+
# The ONE owner of what a Claude transcript usage record means. _ledgerlib persists
|
|
6
|
+
# and schedules, and the statusline renders. Neither re-derives usage semantics.
|
|
7
|
+
#
|
|
8
|
+
# Design principles:
|
|
9
|
+
# - Stdlib only; pure functions over immutable tables; no I/O, no classes.
|
|
10
|
+
# - Money is exact: integer picodollars (1e-12 USD), with every rate and
|
|
11
|
+
# modifier held as an exact decimal/rational. There is no float math anywhere
|
|
12
|
+
# in pricing, so derived totals can be subtracted and re-added exactly.
|
|
13
|
+
# - A normalized fact merges with another fact of the same request by
|
|
14
|
+
# element-wise max (counters), min (event time) and union (days, modifier
|
|
15
|
+
# values). That is commutative, associative and idempotent, so neither scan
|
|
16
|
+
# order nor replay order can change the accepted state (spec D-6).
|
|
17
|
+
# - Unknown things are never priced as something else. They add a coverage
|
|
18
|
+
# reason and contribute no dollars (spec D-8, D-10, D-26, D-27).
|
|
19
|
+
#
|
|
20
|
+
# Public API:
|
|
21
|
+
# rates(model, speed="standard", geo="global") -> tuple|None picodollars/token
|
|
22
|
+
# canonical_model(model) -> str|None
|
|
23
|
+
# known_models() -> list[str]
|
|
24
|
+
# classify_identity(record) -> (key|None, alias|None)
|
|
25
|
+
# normalize(record, day_fn) -> {"kind": "usage"|"malformed"|"ignore", ...}
|
|
26
|
+
# merge(fact_a, fact_b) -> fact
|
|
27
|
+
# price(fact) -> {"pd", "in", "out", "cr", "reasons"}
|
|
28
|
+
# event_day(fact) -> str|None ; days_seen(fact) -> list[str]
|
|
29
|
+
# usage_bearing(line_bytes) -> bool
|
|
30
|
+
|
|
31
|
+
import math
|
|
32
|
+
import os
|
|
33
|
+
import re
|
|
34
|
+
import sys
|
|
35
|
+
from fractions import Fraction
|
|
36
|
+
|
|
37
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
38
|
+
from _fmtlib import parse_iso # noqa: E402
|
|
39
|
+
|
|
40
|
+
# --------------------------------------------------------------------------- pricing registry
|
|
41
|
+
# Anthropic API list prices, USD per million tokens, copied verbatim from the
|
|
42
|
+
# official pricing page. Tuple order: input, output, 5-minute cache write,
|
|
43
|
+
# 1-hour cache write, cache read. Strings keep every value an exact decimal.
|
|
44
|
+
REGISTRY_CAPTURED = "2026-09-27"
|
|
45
|
+
REGISTRY_SOURCE = "https://platform.claude.com/docs/en/about-claude/pricing"
|
|
46
|
+
# Bump whenever a rate or rule changes: stored ledger state re-prices from its
|
|
47
|
+
# retained evidence when the version it was priced under differs (spec D-21).
|
|
48
|
+
REGISTRY_VERSION = "2026-09-27.1"
|
|
49
|
+
|
|
50
|
+
_OPUS_45_TO_5 = ("5", "25", "6.25", "10", "0.50")
|
|
51
|
+
_OPUS_4_41 = ("15", "75", "18.75", "30", "1.50")
|
|
52
|
+
_SONNET_4X = ("3", "15", "3.75", "6", "0.30")
|
|
53
|
+
_FABLE_51 = ("10", "50", "12.50", "20", "0.25")
|
|
54
|
+
_FABLE_5 = ("10", "50", "12.50", "20", "1")
|
|
55
|
+
_PRICES = {
|
|
56
|
+
"claude-fable-5-1": _FABLE_51,
|
|
57
|
+
"claude-mythos-5-1": _FABLE_51,
|
|
58
|
+
"claude-fable-5": _FABLE_5,
|
|
59
|
+
"claude-mythos-5": _FABLE_5,
|
|
60
|
+
"claude-opus-5-5": ("4", "20", "5", "8", "0.20"),
|
|
61
|
+
"claude-opus-5": _OPUS_45_TO_5,
|
|
62
|
+
"claude-opus-4-8": _OPUS_45_TO_5,
|
|
63
|
+
"claude-opus-4-7": _OPUS_45_TO_5,
|
|
64
|
+
"claude-opus-4-6": _OPUS_45_TO_5,
|
|
65
|
+
"claude-opus-4-5": _OPUS_45_TO_5,
|
|
66
|
+
"claude-opus-4-1": _OPUS_4_41,
|
|
67
|
+
"claude-opus-4": _OPUS_4_41,
|
|
68
|
+
"claude-sonnet-5": ("2", "10", "2.50", "4", "0.20"),
|
|
69
|
+
"claude-sonnet-4-6": _SONNET_4X,
|
|
70
|
+
"claude-sonnet-4-5": _SONNET_4X,
|
|
71
|
+
"claude-sonnet-4": _SONNET_4X,
|
|
72
|
+
"claude-haiku-4-5": ("1", "5", "1.25", "2", "0.10"),
|
|
73
|
+
"claude-3-5-haiku": ("0.80", "4", "1", "1.60", "0.08"),
|
|
74
|
+
}
|
|
75
|
+
# Explicit alternate spellings only. Dated snapshot suffixes (-YYYYMMDD) are
|
|
76
|
+
# stripped by rule below; nothing else is guessed.
|
|
77
|
+
_ALIASES = {
|
|
78
|
+
"claude-opus-4-0": "claude-opus-4",
|
|
79
|
+
"claude-sonnet-4-0": "claude-sonnet-4",
|
|
80
|
+
}
|
|
81
|
+
# Fast mode (research preview): published input/output rates. Cache multipliers
|
|
82
|
+
# stack on the fast input rate in the model's own standard ratios.
|
|
83
|
+
_FAST = {
|
|
84
|
+
"claude-opus-5-5": ("8", "40"),
|
|
85
|
+
"claude-opus-5": ("10", "50"),
|
|
86
|
+
"claude-opus-4-8": ("10", "50"),
|
|
87
|
+
}
|
|
88
|
+
# Claude 4.6-and-later models: `inference_geo: "us"` is billed at 1.1x, and the
|
|
89
|
+
# 1M context window is billed at standard rates (so a `[1m]` label is an alias).
|
|
90
|
+
_MODERN = frozenset({
|
|
91
|
+
"claude-fable-5-1", "claude-mythos-5-1", "claude-fable-5", "claude-mythos-5",
|
|
92
|
+
"claude-opus-5-5", "claude-opus-5", "claude-opus-4-8", "claude-opus-4-7",
|
|
93
|
+
"claude-opus-4-6", "claude-sonnet-5", "claude-sonnet-4-6",
|
|
94
|
+
})
|
|
95
|
+
_GEO_US_MULTIPLIER = Fraction(11, 10)
|
|
96
|
+
_STANDARD_SPEED = "standard"
|
|
97
|
+
_STANDARD_GEO = frozenset({"global", "not_available"}) # not_available: see spec D-27
|
|
98
|
+
_STANDARD_TIER = "standard"
|
|
99
|
+
|
|
100
|
+
# Separately metered server-tool counters (spec D-10): picodollars per unit.
|
|
101
|
+
# Web search $10 / 1,000 searches; web fetch has no charge beyond tokens. Any
|
|
102
|
+
# other counter (incl. code execution, billed by unobservable container time)
|
|
103
|
+
# is unknown and makes coverage partial when non-zero.
|
|
104
|
+
TOOL_PRICES = {
|
|
105
|
+
"web_search_requests": 10 * 10**12 // 1000,
|
|
106
|
+
"web_fetch_requests": 0,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
_PD_PER_USD_PER_MTOK = 10**6 # $/MTok -> picodollars per token
|
|
110
|
+
_DATED = re.compile(r"-\d{8}$")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _pd(value):
|
|
114
|
+
v = Fraction(value) * _PD_PER_USD_PER_MTOK
|
|
115
|
+
if v.denominator != 1: # a rate that is not a whole picodollar is a registry bug
|
|
116
|
+
raise ValueError(f"non-integral rate {value!r}")
|
|
117
|
+
return int(v)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def known_models():
|
|
121
|
+
return sorted(_PRICES)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def canonical_model(model):
|
|
125
|
+
"""The registry id for a model string, or None when it is not known."""
|
|
126
|
+
if not isinstance(model, str):
|
|
127
|
+
return None
|
|
128
|
+
s = model.strip().lower()
|
|
129
|
+
long_ctx = s.endswith("[1m]")
|
|
130
|
+
if long_ctx:
|
|
131
|
+
s = s[:-4]
|
|
132
|
+
s = _ALIASES.get(s, s)
|
|
133
|
+
if s not in _PRICES and _DATED.search(s):
|
|
134
|
+
s = _DATED.sub("", s)
|
|
135
|
+
s = _ALIASES.get(s, s)
|
|
136
|
+
if s not in _PRICES:
|
|
137
|
+
return None
|
|
138
|
+
if long_ctx and s not in _MODERN:
|
|
139
|
+
return None # pre-4.6 long context had its own premium
|
|
140
|
+
return s
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def rates(model, speed=_STANDARD_SPEED, geo="global"):
|
|
144
|
+
"""(input, output, 5m write, 1h write, cache read) in integer picodollars per
|
|
145
|
+
token, or None when the model or the modifier combination is not priceable."""
|
|
146
|
+
canon = canonical_model(model)
|
|
147
|
+
if canon is None:
|
|
148
|
+
return None
|
|
149
|
+
base = [Fraction(v) for v in _PRICES[canon]]
|
|
150
|
+
if speed == "fast":
|
|
151
|
+
if canon not in _FAST:
|
|
152
|
+
return None
|
|
153
|
+
fin, fout = (Fraction(v) for v in _FAST[canon])
|
|
154
|
+
base = [fin, fout, fin * base[2] / base[0], fin * base[3] / base[0],
|
|
155
|
+
fin * base[4] / base[0]]
|
|
156
|
+
elif speed != _STANDARD_SPEED:
|
|
157
|
+
return None
|
|
158
|
+
if geo == "us":
|
|
159
|
+
if canon not in _MODERN:
|
|
160
|
+
return None
|
|
161
|
+
base = [v * _GEO_US_MULTIPLIER for v in base]
|
|
162
|
+
elif geo not in _STANDARD_GEO:
|
|
163
|
+
return None
|
|
164
|
+
return tuple(_pd(v) for v in base)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# --------------------------------------------------------------------------- identity
|
|
168
|
+
_MAX_ID = 256
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _valid_id(v):
|
|
172
|
+
return (isinstance(v, str) and 0 < len(v) <= _MAX_ID
|
|
173
|
+
and not any(ord(ch) < 32 or ord(ch) == 127 for ch in v))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def classify_identity(record):
|
|
177
|
+
"""(identity key, message-id alias) for a usage record (spec D-5).
|
|
178
|
+
|
|
179
|
+
Keys are domain-separated by class prefix. A record carrying both IDs
|
|
180
|
+
returns its `mid:` key as an alias, so a message-ID-only representation of
|
|
181
|
+
the same request can be merged in either arrival order. Anything missing
|
|
182
|
+
or structurally invalid is unproven: (None, None)."""
|
|
183
|
+
if not isinstance(record, dict):
|
|
184
|
+
return None, None
|
|
185
|
+
msg = record.get("message")
|
|
186
|
+
mid = msg.get("id") if isinstance(msg, dict) else None
|
|
187
|
+
mid_key = "mid:" + mid if _valid_id(mid) else None
|
|
188
|
+
rid = record.get("requestId")
|
|
189
|
+
if _valid_id(rid):
|
|
190
|
+
return "rid:" + rid, mid_key
|
|
191
|
+
return mid_key, None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# --------------------------------------------------------------------------- normalization
|
|
195
|
+
MAX_COUNTER = 10**15 # sanity ceiling for any single usage counter
|
|
196
|
+
_MIN_EPOCH, _MAX_EPOCH = 946684800, 4102444800 # 2000-01-01 .. 2100-01-01
|
|
197
|
+
_TOP = ("input_tokens", "output_tokens", "cache_read_input_tokens",
|
|
198
|
+
"cache_creation_input_tokens")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class _Malformed(Exception):
|
|
202
|
+
pass
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _count(v):
|
|
206
|
+
"""A usage counter as a non-negative int. Absent/None is 0. Booleans,
|
|
207
|
+
strings, negatives, non-finite, fractional and absurd values are malformed."""
|
|
208
|
+
if v is None:
|
|
209
|
+
return 0
|
|
210
|
+
if isinstance(v, bool):
|
|
211
|
+
raise _Malformed
|
|
212
|
+
if isinstance(v, int):
|
|
213
|
+
n = v
|
|
214
|
+
elif isinstance(v, float):
|
|
215
|
+
if not math.isfinite(v) or not v.is_integer():
|
|
216
|
+
raise _Malformed
|
|
217
|
+
n = int(v)
|
|
218
|
+
else:
|
|
219
|
+
raise _Malformed
|
|
220
|
+
if n < 0 or n > MAX_COUNTER:
|
|
221
|
+
raise _Malformed
|
|
222
|
+
return n
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _split(obj):
|
|
226
|
+
"""(c5, c1) from a cache_creation object, or None when absent."""
|
|
227
|
+
if obj is None:
|
|
228
|
+
return None
|
|
229
|
+
if not isinstance(obj, dict):
|
|
230
|
+
raise _Malformed
|
|
231
|
+
return (_count(obj.get("ephemeral_5m_input_tokens")),
|
|
232
|
+
_count(obj.get("ephemeral_1h_input_tokens")))
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _vector(obj):
|
|
236
|
+
"""[in, out, cache_read, cache_write_total, c5, c1, classified] for one
|
|
237
|
+
usage-shaped object (a top-level usage or one iteration)."""
|
|
238
|
+
inp, out, cr, cw = (_count(obj.get(k)) for k in _TOP)
|
|
239
|
+
split = _split(obj.get("cache_creation"))
|
|
240
|
+
if split is None:
|
|
241
|
+
return [inp, out, cr, cw, 0, 0, 0]
|
|
242
|
+
return [inp, out, cr, cw, split[0], split[1], 1]
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _add(a, b):
|
|
246
|
+
"""Sum two vectors of the SAME representation (several iterations of one kind)."""
|
|
247
|
+
out = [x + y for x, y in zip(a[:6], b[:6])]
|
|
248
|
+
return out + [min(a[6], b[6])]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _model_key(model):
|
|
252
|
+
canon = canonical_model(model)
|
|
253
|
+
if canon is not None:
|
|
254
|
+
return canon
|
|
255
|
+
return "?" + model.strip().lower() if isinstance(model, str) and model.strip() else "?"
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
_MODIFIER_ALIASES = {"geo": {"not_available": "global"}} # D-27: standard pricing
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _modifier(value, kind):
|
|
262
|
+
"""One representation's vote for a price modifier. An absent or empty field
|
|
263
|
+
casts NO vote (a streaming snapshot that omits it must not conflict with one
|
|
264
|
+
that states it); defaults are applied only when pricing the merged fact."""
|
|
265
|
+
if value is None:
|
|
266
|
+
return []
|
|
267
|
+
if isinstance(value, str):
|
|
268
|
+
v = value.strip().lower()
|
|
269
|
+
return [_MODIFIER_ALIASES.get(kind, {}).get(v, v)] if v else []
|
|
270
|
+
return ["invalid"]
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _trusted_ts(ts):
|
|
274
|
+
if not isinstance(ts, str):
|
|
275
|
+
return None
|
|
276
|
+
e = parse_iso(ts)
|
|
277
|
+
if e is None or not (_MIN_EPOCH <= e < _MAX_EPOCH):
|
|
278
|
+
return None
|
|
279
|
+
return e
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def normalize(record, day_fn):
|
|
283
|
+
"""Classify one decoded transcript record.
|
|
284
|
+
|
|
285
|
+
Returns {"kind": "ignore"} for anything that is not billable assistant usage
|
|
286
|
+
(non-objects, other record types, zero-usage records such as `<synthetic>`),
|
|
287
|
+
{"kind": "malformed", "reason": "malformed_record", "id": ...} when usage is
|
|
288
|
+
present but cannot be trusted, or {"kind": "usage", "id", "alias", "fact"}.
|
|
289
|
+
`day_fn(timestamp_string)` maps a trustworthy timestamp to its local day."""
|
|
290
|
+
if not isinstance(record, dict) or record.get("type") != "assistant":
|
|
291
|
+
return {"kind": "ignore"}
|
|
292
|
+
msg = record.get("message")
|
|
293
|
+
if not isinstance(msg, dict) or "usage" not in msg:
|
|
294
|
+
return {"kind": "ignore"}
|
|
295
|
+
ident, alias = classify_identity(record)
|
|
296
|
+
try:
|
|
297
|
+
fact = _fact(record, msg, msg.get("usage"), day_fn)
|
|
298
|
+
except _Malformed:
|
|
299
|
+
return {"kind": "malformed", "reason": "malformed_record", "id": ident}
|
|
300
|
+
if fact is None:
|
|
301
|
+
return {"kind": "ignore"}
|
|
302
|
+
return {"kind": "usage", "id": ident, "alias": alias, "fact": fact}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _fact(record, msg, usage, day_fn):
|
|
306
|
+
if not isinstance(usage, dict):
|
|
307
|
+
raise _Malformed
|
|
308
|
+
comps = {}
|
|
309
|
+
top = _vector(usage)
|
|
310
|
+
iters = usage.get("iterations")
|
|
311
|
+
if iters is None:
|
|
312
|
+
iters = []
|
|
313
|
+
if not isinstance(iters, list):
|
|
314
|
+
raise _Malformed
|
|
315
|
+
msg_split = None
|
|
316
|
+
for it in iters:
|
|
317
|
+
if not isinstance(it, dict):
|
|
318
|
+
raise _Malformed
|
|
319
|
+
kind = it.get("type")
|
|
320
|
+
vec = _vector(it)
|
|
321
|
+
if kind == "message":
|
|
322
|
+
msg_split = vec if msg_split is None else _add(msg_split, vec)
|
|
323
|
+
continue
|
|
324
|
+
if kind == "advisor_message":
|
|
325
|
+
key = "a|" + _model_key(it.get("model"))
|
|
326
|
+
else:
|
|
327
|
+
key = "x|" + (kind if isinstance(kind, str) else "?") + "|" + _model_key(it.get("model"))
|
|
328
|
+
comps[key] = vec if key not in comps else _add(comps[key], vec)
|
|
329
|
+
# The top-level counters are the message iterations (spec observation 2). Their
|
|
330
|
+
# TTL split is taken from the per-iteration splits when those account for the
|
|
331
|
+
# whole aggregate; the top-level split can be stale on multi-iteration records.
|
|
332
|
+
if msg_split is not None and msg_split[6] and msg_split[4] + msg_split[5] == top[3]:
|
|
333
|
+
top[4], top[5], top[6] = msg_split[4], msg_split[5], 1
|
|
334
|
+
comps["m|" + _model_key(msg.get("model"))] = top
|
|
335
|
+
tools = {}
|
|
336
|
+
stu = usage.get("server_tool_use")
|
|
337
|
+
if stu is not None:
|
|
338
|
+
if not isinstance(stu, dict):
|
|
339
|
+
raise _Malformed
|
|
340
|
+
for name, v in stu.items():
|
|
341
|
+
tools[str(name)] = _count(v)
|
|
342
|
+
if not any(any(v[:6]) for v in comps.values()) and not any(tools.values()):
|
|
343
|
+
return None # non-billable (e.g. <synthetic>, all zero)
|
|
344
|
+
t = _trusted_ts(record.get("timestamp"))
|
|
345
|
+
day = day_fn(record["timestamp"]) if t is not None else None
|
|
346
|
+
return {
|
|
347
|
+
"t": t,
|
|
348
|
+
"td": day,
|
|
349
|
+
"d": [day] if day else [],
|
|
350
|
+
"c": comps,
|
|
351
|
+
"tl": tools,
|
|
352
|
+
"md": {"speed": _modifier(usage.get("speed"), "speed"),
|
|
353
|
+
"geo": _modifier(usage.get("inference_geo"), "geo"),
|
|
354
|
+
"tier": _modifier(usage.get("service_tier"), "tier")},
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# --------------------------------------------------------------------------- reconciliation
|
|
359
|
+
def merge(a, b):
|
|
360
|
+
"""Reconcile two facts of the SAME request (spec D-6). Commutative,
|
|
361
|
+
associative and idempotent: counters by max, event time by min, day and
|
|
362
|
+
modifier sets by union. Cache-write classification is a max-join whose
|
|
363
|
+
consistency is judged at pricing time (spec D-9)."""
|
|
364
|
+
if a is None:
|
|
365
|
+
return b
|
|
366
|
+
if b is None:
|
|
367
|
+
return a
|
|
368
|
+
if a["t"] is None:
|
|
369
|
+
t, td = b["t"], b["td"]
|
|
370
|
+
elif b["t"] is None or a["t"] < b["t"]:
|
|
371
|
+
t, td = a["t"], a["td"]
|
|
372
|
+
elif b["t"] < a["t"]:
|
|
373
|
+
t, td = b["t"], b["td"]
|
|
374
|
+
else:
|
|
375
|
+
t, td = a["t"], min(x for x in (a["td"], b["td"]) if x) if (a["td"] or b["td"]) else None
|
|
376
|
+
comps = {}
|
|
377
|
+
for key in set(a["c"]) | set(b["c"]):
|
|
378
|
+
x, y = a["c"].get(key), b["c"].get(key)
|
|
379
|
+
comps[key] = list(x) if y is None else list(y) if x is None else \
|
|
380
|
+
[max(p, q) for p, q in zip(x, y)]
|
|
381
|
+
tools = {k: max(a["tl"].get(k, 0), b["tl"].get(k, 0)) for k in set(a["tl"]) | set(b["tl"])}
|
|
382
|
+
mods = {k: sorted(set(a["md"].get(k, [])) | set(b["md"].get(k, [])))
|
|
383
|
+
for k in set(a["md"]) | set(b["md"])}
|
|
384
|
+
return {"t": t, "td": td, "d": sorted(set(a["d"]) | set(b["d"])), "c": comps,
|
|
385
|
+
"tl": tools, "md": mods}
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def event_day(fact):
|
|
389
|
+
return fact.get("td") if fact else None
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def days_seen(fact):
|
|
393
|
+
return list(fact.get("d") or []) if fact else []
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
# --------------------------------------------------------------------------- pricing a fact
|
|
397
|
+
def _vec_cost(vec, r, reasons):
|
|
398
|
+
"""Picodollars for one component vector at rates `r` (None = unpriced)."""
|
|
399
|
+
inp, out, cr, cw, c5, c1, classified = vec
|
|
400
|
+
if not (classified and c5 + c1 <= cw):
|
|
401
|
+
c5 = c1 = 0 # no classification, or one that cannot be trusted
|
|
402
|
+
if cw - c5 - c1 > 0:
|
|
403
|
+
reasons.add("unknown_cache_ttl")
|
|
404
|
+
if r is None:
|
|
405
|
+
return 0
|
|
406
|
+
return inp * r[0] + out * r[1] + c5 * r[2] + c1 * r[3] + cr * r[4]
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def price(fact):
|
|
410
|
+
"""Tokens and exact picodollars for one accepted request, plus the set of
|
|
411
|
+
coverage reasons that keep it from being completely priced. Display input
|
|
412
|
+
counts uncached input plus every cache write; cache reads are priced but not
|
|
413
|
+
counted as input."""
|
|
414
|
+
reasons = set()
|
|
415
|
+
total = tin = tout = tcr = 0
|
|
416
|
+
comps = fact["c"]
|
|
417
|
+
mods = fact.get("md", {})
|
|
418
|
+
speeds = mods.get("speed") or [_STANDARD_SPEED]
|
|
419
|
+
geos = mods.get("geo") or ["global"]
|
|
420
|
+
tiers = mods.get("tier") or [_STANDARD_TIER]
|
|
421
|
+
modifiers_ok = len(speeds) == 1 and len(geos) == 1 and tiers == [_STANDARD_TIER]
|
|
422
|
+
|
|
423
|
+
top_keys = sorted(k for k in comps if k.startswith("m|"))
|
|
424
|
+
if len(top_keys) > 1:
|
|
425
|
+
# One request cannot have run on two models. Count tokens once (the
|
|
426
|
+
# component-wise max, never a sum) and price nothing until proven.
|
|
427
|
+
reasons.add("model_conflict")
|
|
428
|
+
vec = [max(col) for col in zip(*(comps[k] for k in top_keys))]
|
|
429
|
+
_vec_cost(vec, None, reasons)
|
|
430
|
+
tin, tout, tcr = vec[0] + vec[3], vec[1], vec[2]
|
|
431
|
+
elif top_keys:
|
|
432
|
+
vec = comps[top_keys[0]]
|
|
433
|
+
model = top_keys[0][2:]
|
|
434
|
+
r = None
|
|
435
|
+
if model.startswith("?"):
|
|
436
|
+
reasons.add("unknown_model")
|
|
437
|
+
elif modifiers_ok:
|
|
438
|
+
r = rates(model, speed=speeds[0], geo=geos[0])
|
|
439
|
+
if r is None:
|
|
440
|
+
reasons.add("unpriced_modifier")
|
|
441
|
+
else:
|
|
442
|
+
reasons.add("unpriced_modifier")
|
|
443
|
+
total += _vec_cost(vec, r, reasons)
|
|
444
|
+
tin, tout, tcr = vec[0] + vec[3], vec[1], vec[2]
|
|
445
|
+
|
|
446
|
+
for key in sorted(comps):
|
|
447
|
+
if key.startswith("m|"):
|
|
448
|
+
continue
|
|
449
|
+
vec = comps[key]
|
|
450
|
+
tin += vec[0] + vec[3]
|
|
451
|
+
tout += vec[1]
|
|
452
|
+
tcr += vec[2]
|
|
453
|
+
if key.startswith("a|"):
|
|
454
|
+
model = key[2:]
|
|
455
|
+
r = None if model.startswith("?") else rates(model)
|
|
456
|
+
if r is None and any(vec[:6]):
|
|
457
|
+
reasons.add("unknown_model")
|
|
458
|
+
total += _vec_cost(vec, r, reasons)
|
|
459
|
+
elif any(vec[:6]):
|
|
460
|
+
reasons.add("unknown_iteration")
|
|
461
|
+
|
|
462
|
+
for name, n in sorted(fact.get("tl", {}).items()):
|
|
463
|
+
if n <= 0:
|
|
464
|
+
continue
|
|
465
|
+
if name in TOOL_PRICES:
|
|
466
|
+
total += n * TOOL_PRICES[name]
|
|
467
|
+
else:
|
|
468
|
+
reasons.add("unknown_charge")
|
|
469
|
+
return {"pd": total, "in": tin, "out": tout, "cr": tcr, "reasons": reasons}
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
# --------------------------------------------------------------------------- byte-level classification
|
|
473
|
+
TAIL_WINDOW = 65536
|
|
474
|
+
_USAGE_KEY = b'"usage":'
|
|
475
|
+
_ASSISTANT = (b'"type":"assistant"', b'"type": "assistant"')
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def usage_bearing(line):
|
|
479
|
+
"""True when a raw JSONL line may carry billable assistant usage: its final
|
|
480
|
+
64 KiB contain both a `"usage":` key and an assistant type marker (spec
|
|
481
|
+
observation 10). Used to skip decoding every other line, and to classify an
|
|
482
|
+
undecodable or oversized line."""
|
|
483
|
+
tail = line[-TAIL_WINDOW:]
|
|
484
|
+
return _USAGE_KEY in tail and any(m in tail for m in _ASSISTANT)
|
|
@@ -313,7 +313,7 @@ class Host:
|
|
|
313
313
|
|
|
314
314
|
name = "claude"
|
|
315
315
|
adapter_name = "ca"
|
|
316
|
-
adapter_version = "2.
|
|
316
|
+
adapter_version = "2.23.1"
|
|
317
317
|
|
|
318
318
|
# Update-notifier descriptor. Each independently versioned host overrides
|
|
319
319
|
# these three values in its per-plugin _host.py. Keeping the target,
|
|
@@ -13,19 +13,21 @@
|
|
|
13
13
|
# - native-font glyphs only: box-drawing, block elements, arrows, ASCII labels.
|
|
14
14
|
# - top line = active folder; second line = git project (owner/name + branch),
|
|
15
15
|
# with a no-git fallback.
|
|
16
|
-
# - token
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
16
|
+
# - token counts AND cost are reconstructed from the transcripts (parent plus
|
|
17
|
+
# every subagent transcript, each request counted once) and priced at the
|
|
18
|
+
# list rates pinned in _usagelib: api≈ when complete, api≥ as a lower bound,
|
|
19
|
+
# api≈? when no defensible figure exists. The host's cost.total_cost_usd is a
|
|
20
|
+
# separate estimate, shown as host≈ for Session only when reconstruction is
|
|
21
|
+
# unavailable, and never added to or substituted for an api figure.
|
|
21
22
|
# - context trusts context_window.used_percentage + context_window_size
|
|
22
23
|
# (1M for million-token models, 200K otherwise) — never exceeds 100%.
|
|
23
24
|
#
|
|
24
|
-
# A small user-level ledger (
|
|
25
|
-
# transcript from a stored byte
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
25
|
+
# A small user-level ledger (per-session state under
|
|
26
|
+
# ~/.codearbiter/ledger.json.sessions/) tails each transcript from a stored byte
|
|
27
|
+
# offset within a fixed per-render budget, so the box shows this session, today's
|
|
28
|
+
# totals across sessions, and a sparkline of the parent session's per-request
|
|
29
|
+
# burn. Deferred work is never lost: it resumes on the next host redraw (the
|
|
30
|
+
# statusline has no timer of its own), and until then the figure reads api≥.
|
|
29
31
|
#
|
|
30
32
|
# Robustness contract: this script must NEVER print a traceback. The host pipes
|
|
31
33
|
# arbitrary JSON on stdin and the output lands in the user's terminal. Every
|
|
@@ -106,7 +108,6 @@ except Exception: # pragma: no cover — never let an import break the statusli
|
|
|
106
108
|
try:
|
|
107
109
|
import _ledgerlib
|
|
108
110
|
ledger_update = _ledgerlib.ledger_update
|
|
109
|
-
_tx_accumulate = _ledgerlib._tx_accumulate
|
|
110
111
|
persist_sess_start = _ledgerlib.persist_sess_start
|
|
111
112
|
except Exception: # pragma: no cover — never let an import break the statusline
|
|
112
113
|
_ledgerlib = None
|
|
@@ -295,17 +296,15 @@ try:
|
|
|
295
296
|
_subagentslib.read_subagents,
|
|
296
297
|
_subagentslib.sub_label)
|
|
297
298
|
ACTIVE_WINDOW, SHOW_WINDOW = _subagentslib.ACTIVE_WINDOW, _subagentslib.SHOW_WINDOW
|
|
298
|
-
MAX_SUB_ROWS, MAX_SUB_FILES
|
|
299
|
-
_subagentslib.MAX_SUB_FILES,
|
|
300
|
-
_subagentslib.MAX_SUB_LINES)
|
|
299
|
+
MAX_SUB_ROWS, MAX_SUB_FILES = _subagentslib.MAX_SUB_ROWS, _subagentslib.MAX_SUB_FILES
|
|
301
300
|
except Exception: # pragma: no cover — never let an import break the statusline
|
|
302
301
|
_subagentslib = None
|
|
303
|
-
ACTIVE_WINDOW = SHOW_WINDOW = MAX_SUB_ROWS = MAX_SUB_FILES =
|
|
302
|
+
ACTIVE_WINDOW = SHOW_WINDOW = MAX_SUB_ROWS = MAX_SUB_FILES = 0
|
|
304
303
|
|
|
305
304
|
def subagent_dir(data, root, sid):
|
|
306
305
|
return None
|
|
307
306
|
|
|
308
|
-
def read_subagents(sdir):
|
|
307
|
+
def read_subagents(sdir, rec=None):
|
|
309
308
|
return 0, 0, [], (0, 0)
|
|
310
309
|
|
|
311
310
|
def sub_label(content):
|
|
@@ -354,6 +353,7 @@ try:
|
|
|
354
353
|
EFF_DISP, model_pill, usage_row = _segmentslib.EFF_DISP, _segmentslib.model_pill, _segmentslib.usage_row
|
|
355
354
|
seg_lines, seg_pr, seg_prune = _segmentslib.seg_lines, _segmentslib.seg_pr, _segmentslib.seg_prune
|
|
356
355
|
redshift = _segmentslib.redshift
|
|
356
|
+
cost_cell, USAGE_ROW_FIXED = _segmentslib.cost_cell, _segmentslib.USAGE_ROW_FIXED
|
|
357
357
|
except Exception: # pragma: no cover — never let an import break the statusline
|
|
358
358
|
_segmentslib = None
|
|
359
359
|
EFF_DISP = {}
|
|
@@ -371,7 +371,12 @@ except Exception: # pragma: no cover — never let an import break the statusli
|
|
|
371
371
|
return f"{V2}{model}{RESET}"
|
|
372
372
|
|
|
373
373
|
def usage_row(label, tin, tout, cost, trail=""):
|
|
374
|
-
return f"{label} {fmt_tok(tin)}/{fmt_tok(tout)} {
|
|
374
|
+
return f"{label} {fmt_tok(tin)}/{fmt_tok(tout)} {cost}"
|
|
375
|
+
|
|
376
|
+
USAGE_ROW_FIXED = 30
|
|
377
|
+
|
|
378
|
+
def cost_cell(scope, width, allow_host=False):
|
|
379
|
+
return "api≈?" # no segment lib: never claim a figure it cannot label
|
|
375
380
|
|
|
376
381
|
def seg_lines(data):
|
|
377
382
|
return None
|
|
@@ -401,9 +406,10 @@ except Exception: # pragma: no cover — never let an import break the statusli
|
|
|
401
406
|
# the lib's numeric samples into a colored sparkline, so it keeps the ANSI
|
|
402
407
|
# dependency out of the lib.
|
|
403
408
|
def burn_spark(rec):
|
|
404
|
-
"""Sparkline of recent per-
|
|
405
|
-
|
|
406
|
-
time-extrapolated
|
|
409
|
+
"""Sparkline of recent per-request token burn, parent session only (child
|
|
410
|
+
usage counts toward totals, not burn): real per-API-call totals accumulated
|
|
411
|
+
from the transcript (via _ledgerlib.burn_samples), not a time-extrapolated
|
|
412
|
+
estimate."""
|
|
407
413
|
samples = _ledgerlib.burn_samples(rec) if _ledgerlib is not None else []
|
|
408
414
|
return sparkline(samples) if len(samples) >= 2 else ""
|
|
409
415
|
|
|
@@ -577,8 +583,11 @@ def _render_active_palette(raw):
|
|
|
577
583
|
|
|
578
584
|
led = safe(ledger_update, data, sid)
|
|
579
585
|
if not (isinstance(led, tuple) and len(led) == 3):
|
|
580
|
-
|
|
586
|
+
blank = {"in": 0.0, "out": 0.0, "cost": 0.0, "pd": 0, "state": "unavailable",
|
|
587
|
+
"reasons": [], "stale": False, "host": None}
|
|
588
|
+
led = ({}, dict(blank), dict(blank))
|
|
581
589
|
rec, sess, day = led
|
|
590
|
+
rec = rec if isinstance(rec, dict) else {}
|
|
582
591
|
spark = safe(burn_spark, rec) or ""
|
|
583
592
|
# True session age from Claude Code's session metadata (the wall clock /usage
|
|
584
593
|
# shows, incl. idle gaps); fall back to the current transcript's first message.
|
|
@@ -649,8 +658,15 @@ def _render_active_palette(raw):
|
|
|
649
658
|
# double-height left table; the right column carries the context-window
|
|
650
659
|
# detail. Rate limits live on the header line now, not their own row.
|
|
651
660
|
LW = max(34, min(50, inner // 2)) # proportional: don't starve the ctx panel at narrow widths
|
|
652
|
-
|
|
653
|
-
|
|
661
|
+
# The cost cell carries its provenance label (api≈ / api≥ / api≈? / host≈);
|
|
662
|
+
# widen the usage column just enough that a figure is never clipped
|
|
663
|
+
# mid-number, while the ctx panel keeps at least 8 columns.
|
|
664
|
+
avail = (inner - 11) - USAGE_ROW_FIXED
|
|
665
|
+
scell = safe(cost_cell, sess, avail, allow_host=True) or "api≈?"
|
|
666
|
+
tcell = safe(cost_cell, day, avail) or "api≈?"
|
|
667
|
+
LW = max(LW, min(USAGE_ROW_FIXED + max(vlen(scell), vlen(tcell)), inner - 11))
|
|
668
|
+
srow = safe(usage_row, "Session", sess.get("in"), sess.get("out"), scell, s_trail) or ""
|
|
669
|
+
trow = safe(usage_row, "Today", day.get("in"), day.get("out"), tcell) or ""
|
|
654
670
|
ctxl = safe(seg_ctx_lines, data, max(8, inner - LW - 3)) or [f"{GREY}ctx --{RESET}", ""]
|
|
655
671
|
div = f"{V0}{V}{RESET}"
|
|
656
672
|
box.row(f"{pad(srow, LW)} {div} {ctxl[0]}")
|
|
@@ -663,7 +679,7 @@ def _render_active_palette(raw):
|
|
|
663
679
|
if not compact:
|
|
664
680
|
sdir = safe(subagent_dir, data, root, sid)
|
|
665
681
|
if sdir:
|
|
666
|
-
res = safe(read_subagents, sdir)
|
|
682
|
+
res = safe(read_subagents, sdir, rec)
|
|
667
683
|
if res:
|
|
668
684
|
active, recent, shown, (tin, tout) = res
|
|
669
685
|
if shown:
|