@arbiterforge/ca-pi 0.15.5 → 0.15.7

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.
@@ -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)
@@ -54,4 +54,8 @@ def main(argv=None):
54
54
 
55
55
 
56
56
  if __name__ == "__main__":
57
- raise SystemExit(main())
57
+ try:
58
+ raise SystemExit(main())
59
+ except (_reconciliationlib.ReconciliationError, _artifactlib.ArtifactError, OSError) as exc:
60
+ print(f"codeArbiter: reconciliation failed: {exc}", file=sys.stderr)
61
+ raise SystemExit(1) from exc
@@ -313,7 +313,7 @@ class Host:
313
313
 
314
314
  name = "claude"
315
315
  adapter_name = "ca"
316
- adapter_version = "2.22.5"
316
+ adapter_version = "2.23.0"
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 COUNTS come from the session TRANSCRIPT (deduped per requestId; the
17
- # host sends no cumulative counts). The COST is the host's authoritative
18
- # cost.total_cost_usd — it already prices every call (incl. subagents, which
19
- # live in separate transcripts) the way your bill does; a token*price table
20
- # would miss subagents and drift, so it is used only as a fallback.
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 (~/.codearbiter/ledger.json) tails each session's
25
- # transcript from a stored byte offset (append-only -> O(new lines) per render),
26
- # accumulating true cumulative tokens + API-equivalent cost per session, so the
27
- # box shows this session, today's totals across sessions, and a sparkline of the
28
- # real per-message token burn.
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, MAX_SUB_LINES = (_subagentslib.MAX_SUB_ROWS,
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 = MAX_SUB_LINES = 0
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)} {usd_fine(cost)}"
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-message token burn — real per-API-call totals
405
- accumulated from the transcript (via _ledgerlib.burn_samples), not a
406
- time-extrapolated estimate."""
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
- led = ({}, {"in": 0.0, "out": 0.0, "cost": 0.0}, {"in": 0.0, "out": 0.0, "cost": 0.0})
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
- srow = safe(usage_row, "Session", sess["in"], sess["out"], sess["cost"], s_trail) or ""
653
- trow = safe(usage_row, "Today", day["in"], day["out"], day["cost"]) or ""
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:
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arbiterforge/ca-pi",
3
- "version": "0.15.5",
3
+ "version": "0.15.7",
4
4
  "private": true,
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",