Skip to content

Record the final AI usage tally for the launch post - #499

Merged
kixelated merged 5 commits into
mainfrom
quest/m1/ai-usage
Nov 8, 2026
Merged

kixelated merged 5 commits into
mainfrom
quest/m1/ai-usage

Conversation

@kixelated

@kixelated kixelated commented Nov 7, 2026 •

Copy link
Copy Markdown
Collaborator

Final accounting for building rind, fixing the maintainer's requested stopping point at 2026-11-07 14:32:11 PT (22:32:11 UTC). The launch post, moq-dev/moq.dev#221, merged 2026-11-08 05:34 UTC and is live at https://moq.dev/blog/rind/. This PR completes the tally quest and, since the post is up, the launch-post quest: it deletes both, drops the links to them, and links the post from the README as the launch-post quest asked. No API or wire change.

Results

  • Logged rind work: $4,540.04 at standard API list prices, 10,762,043,382 tokens (96.98% cache reads), across 12 calendar days, October 27 through November 7.
  • Summed agent time: 574.8 hours, comprising Claude Code 559.4 and Codex 15.5. Parallel agents overlap; this is not elapsed project time.
  • Estimated maintainer active time: 28.3 hours, from 602 distinct prompt/answer timestamps with gaps over 15 minutes excluded.
  • Downstream migration work remains separate: $133.15, 347,039,203 tokens, 19.2 agent hours.
  • Third-party bots remain a rough estimate: about $450. Rounded components: Pullfrog $117, CodeRabbit $243, Codex cloud review $45, CodSpeed $43.
  • The published receipt (moq.dev's src/components/rind/data/tally.json) covers Claude Opus 5.5 only: $4,158 list price, 10.30B tokens (10,296,083,460, 98% cache reads), 559.4 Claude Code agent hours, 28.3 maintainer hours, and $40.00 paid for 12 days of a $100/month plan. Paid is a proration, not an invoice. Every published number matches the Opus row and totals below; the full chart JSON here keeps the other models, Codex hours, migrations, and bots, which the post left out.

The earlier tally in moq-dev/rind#400 was $3,870 through November 6, 10:00 PT. This refresh counts newly logged work, handles the added GPT-6 Sol records, applies the long-context premium to 30 GPT-6.1 Sol requests, and retains unknown Haiku cache TTLs explicitly instead of presenting them as known 5-minute writes.

Tokens and cost by model

Token counts are integers. Output includes thinking/reasoning; it is counted once. Haiku's summary-only cost is a lower bound.

Model Fresh input 5m writes 1h writes Unknown-TTL writes Cache reads Output Cost
Claude Haiku 5.5 1,999,275 0 226,613 23,964,695 51,738,981 251,750 $4.62
Claude Opus 5.5 505,224 89,635,249 121,853,488 0 10,047,912,240 36,177,259 $4,158.15
GPT-6 Astra 16,158,454 0 0 0 72,646,528 361,086 $252.29
GPT-6 Sol 224,767 0 0 0 9,496,704 20,160 $2.55
GPT-6.1 Sol 30,900,671 0 0 0 216,587,904 1,164,390 $98.43
GPT-5.6 Sol (Daybreak Blue) 1,384,888 0 0 0 38,684,160 148,896 $23.99

Method

The same scope and host selection as the first tally: Claude Code, its subagents and T3 delegate sessions, alternate Claude configurations, Codex sessions, and maintainer prompts on the workstation and desktop. Count work in rind checkouts, bootstrap work, and outside tasks whose first named repository is rind. MoQ/Bun adoption tasks belong to the separate migration line. Unrelated projects are excluded. Raw logs, prompts, paths, and private session listings are not published.

Only records from October 27, 00:00 PT through the fixed cutoff are counted. Claude messages are deduplicated by API message ID, keeping their most complete usage record; Codex responses are deduplicated by response ID. Before adding host aggregates, the merger asserts that their hashed API message IDs, response IDs, turn IDs, and helper-session keys are disjoint. All four checks pass. A stream's cumulative token-count events are not added to its per-response usage records.

Haiku helper usage also lives in cumulative session counters. Use the last counter before the cutoff, counting detailed helper messages covered by it once. Detailed messages after the last counter are counted independently. Known cache TTLs are preserved; summary-only input uses the short-prompt rate and unknown-TTL writes use the 5-minute rate, so this small line ($4.62) remains a lower bound. Native cumulative dollar counters are not used to substitute for the stated pricing assumptions. The summary-only portion is not assigned to a day. The chart contains totals only, as requested previously.

Claude busy time sums gaps ending in model output or tool results, omitting foreground-subagent waits already counted in their child logs. Codex time counts completed or aborted turns recorded by the cutoff; unfinished turns are omitted from time, while their completed API responses still contribute tokens and cost. Maintainer time uses their own T3/CLI prompts and interactive answers, deduplicated across hosts; consecutive prompts no more than 15 minutes apart form active stretches.

The workstation had no malformed JSON lines. The desktop reported 70 malformed-line encounters while reading its broader log set, including repeat scans for prompt timestamps; its actual classified session listing confirms none belong to counted rind or migration sessions. No malformed counted record was silently omitted.

Prices, checked November 7, 2026

USD per million tokens, standard tier. Anthropic pricing and OpenAI pricing.

Model Input 5m write 1h write Cache read Output
Claude Opus 5.5 4 5 8 0.20 20
Claude Haiku 5.5, prompts up to 100K 0.10 0.125 0.20 0.01 0.50
GPT-6 Astra 10 12.50 n/a 1 50
GPT-6 Sol 2 2.50 n/a 0.20 10
GPT-6.1 Sol 2 2.50 n/a 0.10 10
GPT-5.6 Sol, Daybreak Blue alias 4 5 n/a 0.40 20

Haiku's long-prompt tier costs five times its short tier; exact request records use the applicable tier, and summary-only records cannot recover that distinction. Seventy-four Haiku web searches add $0.74. Opus recorded none. Codex input includes cached tokens: split those out before pricing; reasoning output is already included in output. No counted Codex cache writes were recorded. For requests over 272,000 input tokens, apply the published 2x input/cache and 1.5x output prices to the whole request. Codex logs do not expose the service tier here: these figures are explicitly standard-tier API-price equivalents, not actual subscription charges or priority/ultrafast bills. Daybreak Blue retains the first tally's GPT-5.6 Sol alias assumption. No counted unpublished auto-review model needed a guessed price.

Bots have no local token logs. Through the cutoff, Pullfrog's workflow has 264 runs and 15.78 elapsed runner hours, priced at rind's measured Opus cost per Claude agent hour. For completed runs that includes workflow overhead. GitHub issue/review comments identify 289 distinct CodeRabbit PRs, 53 Codex cloud PRs and 51 CodSpeed PRs. Keep the first tally's $0.84 per-review proxy for those bots. Counts are fresh, rates are explicitly proxies, and bot costs are separate from measured local usage.

Validation

  • Both host scanners finished successfully; no unsupported counted model or observed Claude tier was accepted.
  • Cross-host deduplication assertions pass; chart token sum and rounded cost match the merged aggregates.
  • Scratch checks pass for inclusive cutoff, exclusion after cutoff, repeated messages, helper coverage, and long-context pricing.
  • just ci passes, including 18 report tests and quest validation. quest check --base origin/main passes after the final quest-link edit.
  • After merging origin/main and finishing the launch-post quest: just check and quest check --base origin/main pass.
  • Blog validation is recorded in moq-dev/moq.dev#221.

Chart JSON

The full tally. The post published the Opus-only subset described in Results.

{
  "source": {
    "pr": "https://github.com/moq-dev/rind/pull/499",
    "copied": "2026-11-07"
  },
  "as_of": "2026-11-07T14:32:11-08:00",
  "scope": "Sessions working in a rind checkout, or whose task names moq-dev/rind, from the graviola bootstrap on 2026-10-27",
  "calendar_span": {
    "first": "2026-10-27",
    "last": "2026-11-07",
    "count": 12
  },
  "agent_hours": {
    "Claude Code": 559.4,
    "Codex": 15.5,
    "total": 574.8
  },
  "maintainer_hours": 28.3,
  "tokens_total": 10762043382,
  "tokens_by_model": {
    "Claude Haiku 5.5": {
      "input": 1999275,
      "cache_write_5m": 0,
      "cache_write_1h": 226613,
      "cache_write_unknown": 23964695,
      "cache_read": 51738981,
      "output": 251750
    },
    "Claude Opus 5.5": {
      "input": 505224,
      "cache_write_5m": 89635249,
      "cache_write_1h": 121853488,
      "cache_write_unknown": 0,
      "cache_read": 10047912240,
      "output": 36177259
    },
    "GPT-6 Astra": {
      "input": 16158454,
      "cache_write_5m": 0,
      "cache_write_1h": 0,
      "cache_write_unknown": 0,
      "cache_read": 72646528,
      "output": 361086
    },
    "GPT-6 Sol": {
      "input": 224767,
      "cache_write_5m": 0,
      "cache_write_1h": 0,
      "cache_write_unknown": 0,
      "cache_read": 9496704,
      "output": 20160
    },
    "GPT-6.1 Sol": {
      "input": 30900671,
      "cache_write_5m": 0,
      "cache_write_1h": 0,
      "cache_write_unknown": 0,
      "cache_read": 216587904,
      "output": 1164390
    },
    "GPT-5.6 Sol (Daybreak Blue)": {
      "input": 1384888,
      "cache_write_5m": 0,
      "cache_write_1h": 0,
      "cache_write_unknown": 0,
      "cache_read": 38684160,
      "output": 148896
    }
  },
  "cost_usd_by_model": {
    "Claude Haiku 5.5": 5,
    "Claude Opus 5.5": 4158,
    "GPT-6 Astra": 252,
    "GPT-6 Sol": 3,
    "GPT-6.1 Sol": 98,
    "GPT-5.6 Sol (Daybreak Blue)": 24
  },
  "cost_usd_total": 4540,
  "migrations": {
    "what": "MoQ's rind epic in moq-dev/moq and the Bun fork",
    "cost_usd": 133,
    "tokens_total": 347039203,
    "agent_hours": 19.2
  },
  "bots_rough_estimate": {
    "cost_usd_by_bot": {
      "Pullfrog": 117,
      "CodeRabbit": 243,
      "Codex cloud review": 45,
      "CodSpeed": 43
    },
    "cost_usd_total": 450
  }
}

Reproduction scripts

Run the scanner on each host, retain its aggregates privately, then run the merger and chart writer. Bot source counts above are supplied in bot-counts.json. The merger fails if hosts share API records rather than blindly adding duplicates. No log contents need to leave the hosts except aggregates and hashed deduplication keys.

tally.py

#!/usr/bin/env python3
# Tally rind's AI usage on this host from Claude Code, Codex, and T3 Code logs.
# Reads logs only. Prints aggregates as JSON; with --sessions, lists how each
# non-obvious session was classified (local review only, never published).
import collections, datetime, glob, hashlib, json, os, re, sqlite3, sys, zoneinfo

HOME = os.path.expanduser('~')
TZ = zoneinfo.ZoneInfo('America/Los_Angeles')
START = '2026-10-27T00:00:00-07:00'
END = '2026-11-07T22:32:11+00:00'

# USD per million tokens, standard tier.
CLAUDE = {
    'claude-opus-5-5': dict(input=4, cache_write_5m=5, cache_write_1h=8, cache_read=0.20, output=20),
    'claude-haiku-5-5': dict(input=0.10, cache_write_5m=0.125, cache_write_1h=0.20, cache_read=0.01, output=0.50),
}
HAIKU_SHORT = dict(input=0.10, cache_write_5m=0.125, cache_write_1h=0.20, cache_read=0.01, output=0.50)
OPENAI = {
    'gpt-6.1-sol': dict(input=2, cache_read=0.10, cache_write=2.50, output=10),
    'gpt-6-sol': dict(input=2, cache_read=0.20, cache_write=2.50, output=10),
    'gpt-6.1-terra': dict(input=1, cache_read=0.05, cache_write=1.25, output=5),
    'gpt-6-luna': dict(input=0.10, cache_read=0.01, cache_write=0.125, output=0.50),
    'gpt-6-astra': dict(input=10, cache_read=1, cache_write=12.50, output=50),
    'gpt-daybreak-blue-latest': dict(input=4, cache_read=0.40, cache_write=5, output=20),  # alias of gpt-5.6-sol
}
OPENAI_LONG = 272_000  # over this many input tokens: 2x input and cache, 1.5x output, whole request


def rind_dir(c):
    return bool(re.match(HOME + r'/(worktrees/rind/|\.t3/worktrees/rind/|work/rind)', c or '')
                or re.search(r'/\.tmp/rind[^/]*(/|$)', c or ''))


MIGRATION = r'quest/m\d/rind/|MoQ on rind|\bonto rind\b|\bon rind\b|moq-crypto'


def classify(cwd, text):
    """'rind', 'migration' (moving MoQ or Bun onto rind), 'other', or None (unknown: inherit).

    Outside a rind checkout, a task belongs to the first repository it names.
    """
    t = re.sub(r'<system-reminder>.*?</system-reminder>', '', text or '', flags=re.S).strip()
    if re.search(r'm1/bun(\.md)?[`" ]', t[:400]):
        return 'migration'  # the Bun fork's PR, run from rind's quest
    if rind_dir(cwd) or 'rind/bootstrap' in t:
        return 'rind'
    repo_dir = re.match(HOME + r'/(?:\.t3/)?(?:worktrees|work)/([^/]+)', cwd or '')
    if repo_dir and repo_dir.group(1) not in ('moq', 'moq-wt'):
        return 'other'  # another project's checkout
    if t.startswith('Context handoff'):
        return None  # a summary of an earlier thread: no task of its own
    repo = re.search(r'(?<![/\w])(?:kixelated|moq-dev)/(?!work\b|worktrees\b)([\w.-]+)', t)
    if repo:
        name = repo.group(1)
        if name == 'rind':
            return 'rind'
        if name == 'moq' and re.search(MIGRATION, t[:400]):
            return 'migration'
        return 'other'
    if re.search(MIGRATION, t[:400]):
        return 'migration'
    if re.search(r'\brind\b', t[:400]):
        return 'rind'
    return None


def day(ts):
    return datetime.datetime.fromisoformat(ts.replace('Z', '+00:00')).astimezone(TZ).date().isoformat()


def secs(a, b):
    return (datetime.datetime.fromisoformat(b.replace('Z', '+00:00')) -
            datetime.datetime.fromisoformat(a.replace('Z', '+00:00'))).total_seconds()


def new_bucket():
    return dict(tokens=collections.defaultdict(lambda: collections.Counter()),
                cost=collections.Counter(), day_cost=collections.Counter(),
                day_tokens=collections.Counter(), agent_s=collections.Counter(),
                day_agent_s=collections.Counter(), sessions=collections.Counter(), prompts=[])


B = collections.defaultdict(new_bucket)
LISTING = []
BAD_LINES = [0]
SESSION_CLASS = {}  # Claude session id -> class, inherited by its subagents
CWD_CLASS = collections.defaultdict(set)  # Claude launch directory -> classes, for Codex reviews run there
start_utc = datetime.datetime.fromisoformat(START)
end_utc = datetime.datetime.fromisoformat(END)


def in_range(ts):
    return start_utc <= datetime.datetime.fromisoformat(ts.replace('Z', '+00:00')) <= end_utc


def user_text(m):
    c = m.get('content')
    if isinstance(c, str):
        return c
    if isinstance(c, list):
        if any(isinstance(x, dict) and x.get('type') == 'tool_result' for x in c):
            return None
        return ' '.join(x.get('text', '') for x in c if isinstance(x, dict) and x.get('type') == 'text')
    return ''


# ---- Claude Code -------------------------------------------------------
seen_msg = {}  # message id -> (bucket, model, day, usage), keeps the most complete record
seen_uuid = set()
haiku = {}  # (bucket, session id) -> (timestamp, modelUsage of its last cost-state)
message_session = {}
message_time = {}


def load(f):
    recs = []
    for l in open(f):
        try:
            recs.append(json.loads(l))
        except ValueError:
            BAD_LINES[0] += 1  # a line cut short or garbled
    return recs


def claude_file(f, src):
    recs = load(f)
    cwd = next((r['cwd'] for r in recs if r.get('cwd')), '')
    text = next((user_text(r['message']) for r in recs
                 if r.get('type') == 'user' and not r.get('isMeta') and user_text(r.get('message', {}))), '')
    sub = '/subagents/' in f
    cls = classify(cwd, text)
    if cls is None and sub:
        cls = SESSION_CLASS.get(f.split('/')[-3])
    cls = None if cls == 'other' else cls
    if not sub:
        SESSION_CLASS[os.path.basename(f)[:-6]] = cls
    CWD_CLASS[cwd].add(cls)
    if not rind_dir(cwd):
        est = sum(sum(r['message']['usage'].get(k, 0) for k in ('input_tokens', 'cache_creation_input_tokens', 'output_tokens'))
                  for r in recs if r.get('type') == 'assistant' and r['message'].get('usage'))
        LISTING.append((src, os.path.basename(f), cwd.replace(HOME, '~'), cls, (text or '')[:100].replace('\n', ' '), est // 1000))
    if cls is None:
        return
    b = B[cls]
    b['sessions'][src + (':subagent' if sub else ':session')] += 1
    agent_tools = set()
    prev = None
    last_ts = None
    for r in recs:
        t = r.get('type')
        ts = r.get('timestamp')
        if ts:
            last_ts = ts
        # Cost-state records have no timestamp; they follow the activity they summarize.
        if t == 'cost-state' and r.get('modelUsage') and last_ts and in_range(last_ts):
            key = (cls, r.get('sessionId'))
            if key not in haiku or secs(haiku[key][0], last_ts) >= 0:
                haiku[key] = (last_ts, {k: v for k, v in r['modelUsage'].items() if 'haiku' in k})
        if not ts or not in_range(ts):
            continue
        u = r.get('uuid')
        dup = u in seen_uuid
        if u:
            seen_uuid.add(u)
        if t == 'assistant':
            m = r['message']
            for x in m.get('content') or []:
                if isinstance(x, dict) and x.get('type') == 'tool_use' and x.get('name') in ('Agent', 'Task'):
                    if not (x.get('input') or {}).get('run_in_background'):
                        agent_tools.add(x.get('id'))
            us = m.get('usage')
            if us and m.get('model') in CLAUDE:
                key = m.get('id')
                old = seen_msg.get(key)
                if old is None or us.get('output_tokens', 0) > old[3].get('output_tokens', 0):
                    seen_msg[key] = (old[0] if old else cls, m['model'], day(ts), us)
                    message_session[key] = r.get('sessionId')
                    message_time[key] = ts
            elif us and m.get('model') != '<synthetic>':
                sys.exit(f'unpriced Claude model {m.get("model")} in {f}')
        # Agent busy time: gaps that end in model output or a tool result,
        # except results of foreground subagents (counted in their own file).
        if t in ('assistant', 'user') and not dup:
            if prev is not None:
                busy = False
                if t == 'assistant':
                    busy = True
                elif t == 'user' and user_text(r.get('message', {})) is None:
                    ids = {x.get('tool_use_id') for x in r['message']['content'] if isinstance(x, dict)}
                    busy = not (ids & agent_tools)
                if busy:
                    g = secs(prev, ts)
                    if g > 0:
                        b['agent_s']['claude'] += g
                        b['day_agent_s'][day(ts)] += g
            prev = ts


def claude_root(root, src):
    for f in sorted(glob.glob(os.path.join(root, '*/*.jsonl'))) + sorted(glob.glob(os.path.join(root, '*/*/subagents/*.jsonl'))):
        claude_file(f, src)


# ---- Codex -------------------------------------------------------------
seen_resp = set()
seen_turn = set()


def codex_file(f):
    recs = load(f)
    meta = next((r['payload'] for r in recs if r.get('type') == 'session_meta'), {})
    cwd = meta.get('cwd') or ''
    text = ''
    for r in recs:
        p = r.get('payload') or {}
        if r.get('type') == 'response_item' and p.get('type') == 'message' and p.get('role') == 'user':
            s = ' '.join(x.get('text', '') for x in p.get('content') or [] if isinstance(x, dict))
            if not s.lstrip().startswith(('<environment_context', '# AGENTS.md', '<user_instructions', '<permissions')):
                text = s
                break
    # Outside a rind checkout, Codex runs local reviews for the Claude session there.
    known = CWD_CLASS.get(cwd, set())
    cls = 'rind' if rind_dir(cwd) else next(iter(known)) if len(known) == 1 else None
    if not rind_dir(cwd):
        LISTING.append(('codex', os.path.basename(f), cwd.replace(HOME, '~'), cls, text[:100].replace('\n', ' ')))
    if cls is None:
        return
    b = B[cls]
    b['sessions']['codex:session'] += 1
    model = None
    started = {}
    have_records = any(r.get('type') == 'token_usage_record' for r in recs)
    for r in recs:
        p = r.get('payload') or {}
        ts = r.get('timestamp')
        if r.get('type') == 'turn_context':
            model = p.get('model')
        if not ts or not in_range(ts):
            continue
        us = None
        if r.get('type') == 'token_usage_record':
            if p['response_id'] in seen_resp:
                continue
            seen_resp.add(p['response_id'])
            us = p['usage']
        elif not have_records and p.get('type') == 'token_count' and p.get('info'):
            us = p['info']['last_token_usage']
        if us:
            add_openai(b, model, ts, us, f)
        if p.get('type') == 'task_started':
            started[p.get('turn_id')] = ts
        if p.get('type') in ('task_complete', 'turn_aborted') and p.get('turn_id') in started:
            tid = p.get('turn_id')
            if tid not in seen_turn:
                seen_turn.add(tid)
                g = secs(started[tid], ts)
                b['agent_s']['codex'] += g
                b['day_agent_s'][day(ts)] += g


def add_openai(b, model, ts, us, f):
    name = model
    if name == 'codex-auto-review':
        name = 'gpt-6.1-sol'  # unpublished; priced as the default Codex model
    pr = OPENAI.get(name)
    if pr is None:
        sys.exit(f'unpriced Codex model {model} in {f}')
    cached = us.get('cached_input_tokens', 0)
    write = us.get('cache_write_input_tokens', 0)
    fresh = us['input_tokens'] - cached - write
    assert fresh >= 0, us
    out = us['output_tokens']
    long_ = us['input_tokens'] > OPENAI_LONG
    mi, mo = (2, 1.5) if long_ else (1, 1)
    cost = (fresh * pr['input'] * mi + cached * pr['cache_read'] * mi + write * pr['cache_write'] * mi
            + out * pr['output'] * mo) / 1e6
    tk = b['tokens'][model]
    tk['input'] += fresh
    tk['cache_read'] += cached
    tk['cache_write'] += write
    tk['output'] += out
    tk['reasoning_in_output'] += us.get('reasoning_output_tokens', 0)
    tk['long_context_requests'] += long_
    tk['requests'] += 1
    b['cost'][model] += cost
    d = day(ts)
    b['day_cost'][d] += cost
    b['day_tokens'][d] += fresh + cached + write + out


def codex_root(root):
    for f in sorted(glob.glob(os.path.join(root, '**/*.jsonl'), recursive=True)):
        codex_file(f)


# ---- T3 Code: the maintainer's own prompts ------------------------------
def t3_prompts(db):
    if not os.path.exists(db):
        return
    c = sqlite3.connect(f'file:{db}?mode=ro', uri=True)
    proj = dict(c.execute('select project_id, workspace_root from projection_projects'))
    first = {}
    msgs = []
    for tid, pid, pl in c.execute(
            "select m.thread_id, t.project_id, m.payload_json from orchestration_v2_projection_messages m "
            "join orchestration_v2_projection_threads t using (thread_id) where m.role = 'user' order by m.created_at"):
        p = json.loads(pl)
        first.setdefault(tid, (proj.get(pid, ''), p.get('text', '')))
        if p.get('createdBy') == 'user' and p.get('creationSource') in ('web', 'mobile'):
            msgs.append((tid, p['createdAt']))
    # Answers to an agent's interactive question count like prompts.
    msgs += c.execute("select thread_id, resolved_at from orchestration_v2_projection_runtime_requests "
                      "where kind = 'user_input' and status = 'resolved'").fetchall()
    for tid, ts in msgs:
        if tid not in first:
            continue
        root, text = first[tid]
        cls = classify('', text)
        cls = 'rind' if cls is None and root.endswith('/rind') else cls
        cls = None if cls == 'other' else cls
        if cls and in_range(ts):
            B[cls]['prompts'].append(ts)


def cli_prompts():
    # Prompts typed straight into a terminal session, outside T3 Code.
    for root in [HOME + '/.claude/projects', HOME + '/.claude-api-credits/projects', HOME + '/.claude-personal/projects']:
        for f in glob.glob(os.path.join(root, '*/*.jsonl')):
            recs = load(f)
            cwd = next((r['cwd'] for r in recs if r.get('cwd')), '')
            text = next((user_text(r['message']) for r in recs
                         if r.get('type') == 'user' and not r.get('isMeta') and user_text(r.get('message', {}))), '')
            cls = classify(cwd, text)
            if cls in (None, 'other'):
                continue
            for r in recs:
                if r.get('type') == 'user' and r.get('entrypoint') == 'cli' and not r.get('isMeta') \
                        and user_text(r['message']) and not user_text(r['message']).startswith('<task-notification') \
                        and in_range(r['timestamp']):
                    B[cls]['prompts'].append(r['timestamp'])


def main():
    for d, src in [('.claude/projects', 'claude'), ('.claude-api-credits/projects', 'claude-api-credits'),
                   ('.claude-personal/projects', 'claude-personal')]:
        if os.path.isdir(os.path.join(HOME, d)):
            claude_root(os.path.join(HOME, d), src)
    for mid, (cls, model, d, us) in seen_msg.items():
        pr = CLAUDE[model]
        state = haiku.get((cls, message_session[mid]))
        if 'haiku' in model and state and model in state[1] and secs(message_time[mid], state[0]) >= 0:
            # The native session counter includes subagents. Do not also bill their API records.
            continue
        cc = us.get('cache_creation') or {}
        w5, w1 = cc.get('ephemeral_5m_input_tokens', 0), cc.get('ephemeral_1h_input_tokens', 0)
        if w5 + w1 != us.get('cache_creation_input_tokens', 0):
            w5 = us.get('cache_creation_input_tokens', 0) - w1
        if 'haiku' in model and us['input_tokens'] + w5 + w1 + us['cache_read_input_tokens'] > 100_000:
            pr = {k: v * 5 for k, v in pr.items()}
        if us.get('speed') not in (None, 'standard') or us.get('service_tier') not in (None, 'standard'):
            sys.exit(f'unpriced tier {us}')
        web = (us.get('server_tool_use') or {}).get('web_search_requests', 0)
        tk = B[cls]['tokens'][model]
        tk['input'] += us['input_tokens']
        tk['cache_write_5m'] += w5
        tk['cache_write_1h'] += w1
        tk['cache_read'] += us['cache_read_input_tokens']
        tk['output'] += us['output_tokens']
        tk['web_searches'] += web
        tk['requests'] += 1
        cost = (us['input_tokens'] * pr['input'] + w5 * pr['cache_write_5m'] + w1 * pr['cache_write_1h']
                + us['cache_read_input_tokens'] * pr['cache_read'] + us['output_tokens'] * pr['output']) / 1e6 + web * 0.01
        B[cls]['cost'][model] += cost
        B[cls]['day_cost'][d] += cost
        B[cls]['day_tokens'][d] += us['input_tokens'] + w5 + w1 + us['cache_read_input_tokens'] + us['output_tokens']
    for (cls, _), (stamp, mu) in haiku.items():
        for model, v in mu.items():
            tk = B[cls]['tokens'][model]
            # Legacy counters cannot recover request context lengths or cache TTLs.
            # Keep their write split unknown and price them at the short/5m lower bound.
            w5 = v.get('cacheCreationEphemeral5mInputTokens', 0)
            w1 = v.get('cacheCreationEphemeral1hInputTokens', 0)
            unknown = v['cacheCreationInputTokens'] - w5 - w1
            assert unknown >= 0, v
            tk['input'] += v['inputTokens']
            tk['cache_write_5m'] += w5
            tk['cache_write_1h'] += w1
            tk['cache_write_unknown'] += unknown
            tk['cache_read'] += v['cacheReadInputTokens']
            tk['output'] += v['outputTokens']
            tk['web_searches'] += v.get('webSearchRequests', 0)
            B[cls]['cost'][model] += (v['inputTokens'] * HAIKU_SHORT['input']
                + (w5 + unknown) * HAIKU_SHORT['cache_write_5m'] + w1 * HAIKU_SHORT['cache_write_1h']
                + v['cacheReadInputTokens'] * HAIKU_SHORT['cache_read']
                + v['outputTokens'] * HAIKU_SHORT['output']) / 1e6 + v.get('webSearchRequests', 0) * .01
    if os.path.isdir(HOME + '/.codex/sessions'):
        codex_root(HOME + '/.codex/sessions')
    t3_prompts(HOME + '/.t3/userdata/statev2.sqlite')
    cli_prompts()
    out = {}
    for cls, b in B.items():
        out[cls] = dict(tokens={m: dict(v) for m, v in b['tokens'].items()}, cost=dict(b['cost']),
                        day_cost=dict(b['day_cost']), day_tokens=dict(b['day_tokens']),
                        agent_s=dict(b['agent_s']), day_agent_s=dict(b['day_agent_s']),
                        sessions=dict(b['sessions']), prompts=sorted(b['prompts']))
    out['bad_lines'] = BAD_LINES[0]
    digest = lambda value: hashlib.sha256(str(value).encode()).hexdigest()
    out['_audit'] = dict(cutoff=END, claude_messages=sorted(digest(x) for x in seen_msg),
                         codex_responses=sorted(digest(x) for x in seen_resp),
                         codex_turns=sorted(digest(x) for x in seen_turn),
                         haiku_sessions=sorted(digest(x) for x in haiku))
    json.dump(out, sys.stdout)
    if '--sessions' in sys.argv:
        for row in LISTING:
            print('\t'.join(str(x) for x in row), file=sys.stderr)


if __name__ == '__main__':
    main()

merge.py

#!/usr/bin/env python3
# Merge per-host tallies into the chart JSON. Usage: merge.py host.json...
import collections, datetime, json, sys, zoneinfo

TZ = zoneinfo.ZoneInfo('America/Los_Angeles')
AWAY = 15 * 60  # a gap between prompts longer than this counts as away


def ts(s):
    return datetime.datetime.fromisoformat(s.replace('Z', '+00:00'))


hosts = [json.load(open(f)) for f in sys.argv[1:]]
# Summing per-host aggregates is valid only after proving their API records disjoint.
for i, a in enumerate(hosts):
    for b in hosts[i + 1:]:
        assert a['_audit']['cutoff'] == b['_audit']['cutoff']
        for key in ('claude_messages', 'codex_responses', 'codex_turns', 'haiku_sessions'):
            overlap = set(a['_audit'][key]) & set(b['_audit'][key])
            assert not overlap, f'Cross-host duplicate {key}: {len(overlap)}'
out = {}
for cls in ('rind', 'migration'):
    tokens = collections.defaultdict(collections.Counter)
    cost = collections.Counter()
    day = collections.defaultdict(collections.Counter)
    agent = collections.Counter()
    prompts = set()
    for h in hosts:
        b = h.get(cls)
        if not b:
            continue
        for m, t in b['tokens'].items():
            tokens[m].update(t)
        cost.update(b['cost'])
        agent.update(b['agent_s'])
        for d, v in b['day_cost'].items():
            day[d]['cost_usd'] += v
        for d, v in b['day_tokens'].items():
            day[d]['tokens'] += v
        for d, v in b['day_agent_s'].items():
            day[d]['agent_hours'] += v / 3600
        prompts.update(b['prompts'])
    # Maintainer hours: consecutive prompts within AWAY of each other form one active stretch.
    ps = sorted(ts(p) for p in prompts)
    active = 0.0
    for a, b in zip(ps, ps[1:]):
        g = (b - a).total_seconds()
        if g <= AWAY:
            active += g
            day[b.astimezone(TZ).date().isoformat()]['maintainer_hours'] += g / 3600
    days = sorted(d for d in day if day[d]['tokens'] or day[d]['agent_hours'])
    out[cls] = {
        'tokens_by_model': {m: {k: int(v) for k, v in t.items()} for m, t in sorted(tokens.items())},
        'cost_usd_by_model': {m: round(v, 2) for m, v in sorted(cost.items())},
        'cost_usd': round(sum(cost.values()), 2),
        'calendar_days': {'first': days[0], 'last': days[-1],
                          'count': (datetime.date.fromisoformat(days[-1]) - datetime.date.fromisoformat(days[0])).days + 1},
        'agent_hours': {k: round(v / 3600, 1) for k, v in sorted(agent.items())} | {'total': round(sum(agent.values()) / 3600, 1)},
        'maintainer_hours': round(active / 3600, 1),
        'maintainer_prompts': len(ps),
        'per_day': [{'date': d, 'cost_usd': round(day[d]['cost_usd'], 2), 'tokens': int(day[d]['tokens']),
                     'agent_hours': round(day[d]['agent_hours'], 1),
                     'maintainer_hours': round(day[d]['maintainer_hours'], 1)} for d in days],
    }
json.dump(out, sys.stdout, indent=1)
print()

chart.py

import json, sys
m = json.load(open(sys.argv[1]))
bc = json.load(open(sys.argv[2]))
NAMES = {'claude-opus-5-5': 'Claude Opus 5.5', 'claude-haiku-5-5': 'Claude Haiku 5.5', 'gpt-6-astra': 'GPT-6 Astra',
         'gpt-6.1-sol': 'GPT-6.1 Sol', 'gpt-6-sol': 'GPT-6 Sol', 'gpt-6.1-terra': 'GPT-6.1 Terra', 'gpt-6-luna': 'GPT-6 Luna', 'gpt-daybreak-blue-latest': 'GPT-5.6 Sol (Daybreak Blue)'}
TYPES = ['input', 'cache_write_5m', 'cache_write_1h', 'cache_write_unknown', 'cache_read', 'output']
def toks(b):
    out = {}
    for mid, t in b['tokens_by_model'].items():
        if mid not in NAMES:
            raise ValueError(f'Unmapped model {mid}')
        if t.get('cache_write', 0):
            raise ValueError('OpenAI cache writes need an explicit chart field')
        out[NAMES[mid]] = {k: t.get(k, 0) for k in TYPES}
    return out
r, g = m['rind'], m['migration']
tk = toks(r)
total_tokens = sum(sum(v.values()) for v in tk.values())
per_review = bc['usd_per_review_proxy']
bots = {'Pullfrog': round(bc['pullfrog_hours'] * r['cost_usd_by_model']['claude-opus-5-5'] / r['agent_hours']['claude']),
        'CodeRabbit': round(bc['coderabbit_prs'] * per_review),
        'Codex cloud review': round(bc['codex_cloud_prs'] * per_review),
        'CodSpeed': round(bc['codspeed_prs'] * per_review)}
chart = {
    'source': {'pr': 'https://github.com/moq-dev/rind/pull/499', 'copied': '2026-11-07'},
    'as_of': '2026-11-07T14:32:11-08:00',
    'scope': 'Sessions working in a rind checkout, or whose task names moq-dev/rind, from the graviola bootstrap on 2026-10-27',
    'calendar_span': r['calendar_days'],
    'agent_hours': {'Claude Code': r['agent_hours']['claude'], 'Codex': r['agent_hours']['codex'], 'total': r['agent_hours']['total']},
    'maintainer_hours': r['maintainer_hours'],
    'tokens_total': total_tokens,
    'tokens_by_model': tk,
    'cost_usd_by_model': {NAMES[k]: round(v) for k, v in r['cost_usd_by_model'].items() if k in NAMES},
    'cost_usd_total': round(r['cost_usd']),
    'migrations': {'what': "MoQ's rind epic in moq-dev/moq and the Bun fork",
                   'cost_usd': round(g['cost_usd']), 'tokens_total': sum(sum(v.values()) for v in toks(g).values()),
                   'agent_hours': g['agent_hours']['total']},
    'bots_rough_estimate': {'cost_usd_by_bot': bots, 'cost_usd_total': round(sum(bots.values()), -1)},
}
json.dump(chart, sys.stdout, indent=1)

Models

Size: S
Implementer: Codex, gpt-6.1-sol, xhigh effort
Implementer: Claude Code, claude-opus-5-5, high effort

Review

Rewritten by quest review after each round.

  • Reviewer: Claude Code, claude-sonnet-5-5, medium effort
  • Session: 93b68453-8a2d-45eb-a3e9-40f31c5c5dcb, round 1
  • Head: cfccde9, named in full once a round reports no findings
  • Base: origin/main at ea5e690
  • Score: 9/10, The change is docs and quest bookkeeping only, with no code or crypto changes, and it lands after the stale launch-post wording is updated.
  • Findings, open until the next round settles them:
    • Low, quest/m2/adoption/snow-write-message.md:13: Quest still says it waits for the launch post, which is now published
    • Low, README.md:37: External blog link is unverified and the AI-usage tally method is no longer recorded

All findings were low severity, so no further round: head 9a53f45. 1 fixed in 9a53f45: the snow and str0m quests and the adoption README now say the launch post is up and link it. 2 refuted: https://moq.dev/blog/rind/ returned HTTP 200 on 2026-11-08, and the deleted tally quest's settled Plan chose "the numbers and the method in the PR body ... with nothing new committed", so the method lives in this body and moq-dev/rind#400 by design.

The reviewer is Sonnet 5.5 through the maintainer's CLIProxy gateway: quest review excluded Opus 5.5 because this Claude Code session (Opus 5.5) committed the quest-text follow-ups. An earlier Sonnet 5.5 round at low effort (session a2bd2387-0acf-49dd-ba9f-689b1f073951) returned no analysis, so it was rerun once as a new session at medium effort.

Quest-Claim: 1e28ee8562412887
@kixelated kixelated changed the title The launch post can say what building rind cost in AI tokens, time, and money Record the final AI usage tally for the launch post Nov 7, 2026
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
kixelated and others added 3 commits November 8, 2026 10:25
moq-dev/moq.dev#221 merged and https://moq.dev/blog/rind/ is live, so
delete the launch-post quest and the links to it, and link the post from
the README as that quest asked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated
kixelated marked this pull request as ready for review November 8, 2026 18:34
@coderabbitai

coderabbitai Bot commented Nov 8, 2026

Copy link
Copy Markdown

Explain Change →

No actionable comments were generated in the recent review.

Walkthrough

The README adds a link to the launch post. The m1 checklist removes launch-post and AI-usage requirements, and the related quest documents are deleted. The m2 adoption guidance no longer makes outside posts conditional on publishing the launch post. It states that the post is up and retains draft PR and AI-disclosure instructions.

Merge Risk: Minimal · up to 9a53f

The documentation links the published launch post and updates outreach prerequisites. No actionable merge-blocking risk is identified.

Pre-merge checks: Passed 5

Check name Status Explanation
Title check Passed The title "Record the final AI usage tally for the launch post" matches the main purpose of the PR. The PR records the final AI usage tally through the fixed cutoff, and it also deletes the completed …
Description check Passed The description is directly related to the changeset. It reports the final rind AI-usage tally figures, explains the scope, pricing, and deduplication methods, and states that the PR deletes the tally…
Docstring Coverage Passed No eligible functions found in the changed files. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentat…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Title check

Explanation

The title "Record the final AI usage tally for the launch post" matches the main purpose of the PR. The PR records the final AI usage tally through the fixed cutoff, and it also deletes the completed tally and launch-post quests and links the launch post from the README. The title covers the primary change, and the description and objectives confirm it.

Full details: Description check

Explanation

The description is directly related to the changeset. It reports the final rind AI-usage tally figures, explains the scope, pricing, and deduplication methods, and states that the PR deletes the tally and launch-post quests and links the launch post from the README. It also cites the earlier tally PR and the validation steps.

Full details: Docstring Coverage

Explanation

No eligible functions found in the changed files. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 0 functions across 0 files. (4 skipped: 4 unsupported.)

Finishing Touches

Simplify code

  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

Recent review info

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 9cc62d9f-1a75-41f0-8fdc-bc0d76a7aa5f
Commits

Reviewing files that changed from the base of the PR and between ea5e690 and 9a53f45.

Files selected for processing: 7

  • README.md
  • quest/m1/README.md
  • quest/m1/ai-usage.md
  • quest/m1/launch-post.md
  • quest/m2/adoption/README.md
  • quest/m2/adoption/snow-write-message.md
  • quest/m2/adoption/str0m-upstream.md
Files with no reviewable changes: 2

  • quest/m1/launch-post.md
  • quest/m1/ai-usage.md

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes — quest bookkeeping and a README link only: the tally and launch-post quests are deleted with every reference to them cleaned up, and the live launch post is linked where outside work waited on it. The tally itself is the report in this PR body, and every externally checkable claim in it verified:

  • Two quests closed — quest/m1/ai-usage.md and quest/m1/launch-post.md are deleted, their bullets are removed from quest/m1/README.md, the Required entries pointing at the launch-post quest are gone from snow-write-message.md and str0m-upstream.md, and a whole-tree grep finds no stale reference to either deleted file. The emptied Required sections match the many existing quests that have none.
  • Launch post linked and live — https://moq.dev/blog/rind/ returns the published post (dated November 8, 2026), and its receipt matches the PR's Opus row exactly: $4,158 list price, 10.30B tokens, 98% cache reads, 559.4 agent hours, 28.3 maintainer hours, $40.00 paid.
  • Tally arithmetic is internally consistent — the six model rows sum to exactly 10,762,043,382 tokens, cache reads are 96.98% of that, each per-model cost recomputes from the stated token counts (including the 30 long-context GPT-6.1 Sol requests at 2× input/cache and 1.5× output), and the rounded chart costs sum to $4,540.
  • Price table matches the live pricing pages — the Anthropic and OpenAI standard-tier rows check out exactly, including Haiku's 5× long-prompt tier, the $0.01-per-search rate behind the $0.74 web-search charge, and the 272K short/long-context boundary with 2× input/cache and 1.5× output long rates.
  • The delta from #400 is coherent — the "$3,870 through November 6, 10:00 PT" citation is exact; shared history agrees (Opus 5-minute writes identical at 89,635,249, Astra and Daybreak Blue rows unchanged), Haiku's 23,964,695 cache writes moved from "5m" to "unknown TTL" exactly as the Method says, GPT-6 Sol appears for the first time, and the bot line recomputes ($117 + $243 + $45 + $43 → about $450).

Pullfrog  | View workflow run | Using glm-5.3 | 𝕏

@kixelated
kixelated added this pull request to the merge queue Nov 8, 2026
Merged via the queue into main with commit cdacc5e Nov 8, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant