Skip to content

Send transcript model usage on PostToolUse so plain claude sessions get costed (0.18.0) - #36

Merged
davidcrowe merged 1 commit into
mainfrom
feat/transcript-model-usage
Sep 16, 2026
Merged

davidcrowe merged 1 commit into
mainfrom
feat/transcript-model-usage

Conversation

@davidcrowe

Copy link
Copy Markdown
Collaborator

Why

gatewaystack-connect#932 (2026-09-02) shipped the gateway-side collector — transcriptUsage.ts: sanitizeModelUsage + recordTranscriptUsage, fully live, no rollout gate — and a client-side collectTranscriptUsage. But the client half landed only in the marketing installer's bundled offline fallback engine (install.sh heredoc, PLUGIN_VERSION = "0.5.0"), which runs only when the GitHub fetch fails at install time. Online installs fetch this file, and for Claude Code the installer removes the direct govern.mjs wiring once the marketplace plugin is in — so ~/.acp/govern.mjs is not even invoked.

git log --all -S'model_usage' on this repo returns nothing through 0.17.0. The gateway has been waiting two weeks for a field no shipped client has ever sent; every external harness tenant has zero costed runs.

This ports the collector into the hook that actually executes. Once shipped, cost starts working for every existing plain-claude user with no change to how they launch and no proxy.

What

  • Every hook payload carries transcript_path; the session JSONL records each assistant turn's model + usage. On PostToolUse the hook reads what arrived since its last report (per-transcript byte offset in ~/.acp/transcript-offsets.json) and sends the turns as model_usage on the /govern/tool-output call it already makes. Only complete lines are consumed; a torn trailing line waits for the next call.
  • Shape is exactly what transcriptUsage.ts:78–98 validates: string id (message id → requestIduuid) and model; four non-negative integer counts (missing = 0); optional ISO ts. Same-id streamed repeats collapse to the last. At most 2 MB read and 50 turns sent per call. The offsets file drops transcripts that no longer exist and keeps at most 40 others.
  • Absent → absent. No transcript_path, a missing / empty / unreadable / malformed transcript, or nothing new since last time all leave the key out entirely — never an empty array. The whole collector is wrapped in try/catch, so nothing in it can reach the decision or delay the call. PreToolUse is untouched.
  • Version → 0.18.0 (plugin.json, marketplace.json, PLUGIN_VERSION) so attestation compares the hash against a fresh version rather than flagging an edited 0.17.0 hook.
  • README: a short "Model-call cost from the transcript (v0.18.0+)" section.

Semantics mirror the reference implementation in the installer heredoc (install.sh:730–785, :803), including advancing the offset before the request goes out: a failed POST loses that window rather than risking a double-count (the gateway's (session, id) dedupe is in-process only). Worth revisiting if under-reporting shows up in the data.

Tests

node --test204 pass, 0 fail (192 existing + 12 new in test/transcript-model-usage.test.mjs, which spawns the real hook against a stub gateway):

  • well-formed turns sent in the gateway's shape; missing counts default to 0
  • malformed / non-assistant / id-less / model-less lines skipped; same-id repeats keep the last; id fallback order
  • no transcript_path → no key; missing / empty / user-only transcript → no key
  • offset advances; nothing new → no key; partial trailing line waits
  • 50-turn cap keeps the newest; corrupt offsets file ignored
  • a directory or non-UTF-8 bytes at transcript_path leaves the PostToolUse outcome unchanged; PreToolUse body unchanged

Also verified by hand against a real 2.4 MB local Claude Code transcript: 50 entries, every one passes the gateway's shape check (claude-opus-5, cache-read counts in the 200k range, real message ids and timestamps).

Conflicts / merge order

This PR and the unpriced-session notice PR (feat/unpriced-session-notice) both touch bin/govern.mjs and both bump the version; they are branched independently off main, not stacked. Merge this one first (it fixes the cost gap for everyone; the notice only tells people about it), then rebase the notice PR — its bump is already 0.19.0 so the rebase is mechanical.

Not merged, not published to npm, not deployed. Publishing is yours.

… get costed (0.18.0)

gatewaystack-connect#932 shipped the gateway-side collector on 2026-09-02
(transcriptUsage.ts: sanitizeModelUsage + recordTranscriptUsage, fully
live, no rollout gate) together with a client-side collectTranscriptUsage —
but only inside the marketing installer's bundled OFFLINE fallback engine,
which runs when the GitHub fetch fails at install time. Online installs
fetch this file, and for Claude Code the installer removes the direct
govern.mjs wiring once the marketplace plugin is in, so ~/.acp/govern.mjs
is not even invoked. `git log --all -S'model_usage'` on this repo returned
nothing through 0.17.0. The gateway has been waiting two weeks for a field
no shipped client ever sent; every external harness tenant has zero costed
runs. This is the port.

- Every hook payload carries transcript_path; the session JSONL records
  each assistant turn's model + usage (input, cache-read, cache-write,
  output tokens). PostToolUse reads what arrived since the last report (a
  per-transcript byte offset in ~/.acp/transcript-offsets.json) and sends
  the turns as `model_usage` on the /govern/tool-output call it already
  makes. Only complete lines are consumed; a torn trailing line waits.
- Shape is exactly what transcriptUsage.ts validates: string id (message
  id, else requestId, else uuid) and model, four non-negative integer
  counts (missing = 0), optional ISO ts. Same-id streamed repeats collapse
  to the last. At most 2 MB read and 50 turns sent per call; the offsets
  file drops transcripts that no longer exist and keeps at most 40 others.
- Absent → absent: no transcript_path, a missing / empty / unreadable /
  malformed transcript, or nothing new since last time all leave the key
  out entirely (never an empty array). The whole collector is wrapped, so
  no failure in it can reach the decision or delay the call; PreToolUse
  is untouched.
- Version 0.18.0 (plugin.json, marketplace.json, PLUGIN_VERSION) so the
  attestation hash is compared against a fresh version, not flagged as an
  edited 0.17.0 hook.

Tests (test/transcript-model-usage.test.mjs, spawns the real hook against
a stub gateway): well-formed turns sent in the gateway's shape; missing
counts default to 0; malformed / non-assistant / id-less / model-less
lines skipped and same-id repeats keep the last; id fallback order; no
transcript_path → no key; missing / empty / user-only transcript → no key;
offset advances and nothing-new → no key; partial trailing line waits;
50-turn cap keeps the newest; corrupt offsets file ignored; a directory or
non-UTF-8 bytes at transcript_path leaves the PostToolUse outcome
unchanged; PreToolUse body unchanged. Also verified by hand against a real
2.4 MB local transcript: 50 entries, every one passes the gateway's check.
@davidcrowe
davidcrowe merged commit eaee19e into main Sep 16, 2026
2 checks passed
davidcrowe added a commit that referenced this pull request Sep 16, 2026
…licy half it lacks

#36 made plain `claude` priced — from the transcript, at API-rate
equivalents — so "neither priced nor policy-checked" became false the
moment it merged. Rewritten to lead with what the session has and name
only what still needs the launcher:

  [ACP] Tool calls in this session are checked and logged, and model-call
  cost is estimated from the session transcript (API-rate equivalent, not
  a metered charge). Model calls are not policy-checked: plain `claude`
  sends them straight to the provider, so tool-result redaction and model
  routing are off. For those, launch with `claude-acp`
  (~/.acp/bin/claude-acp). Shown once per session.

The estimate-vs-metered distinction is stated up front so it does not
arrive later as a support question. The wording is chosen by the same
condition that makes the session priced — a readable transcript_path on
the payload — so a harness that hands over no transcript still gets the
plainer "neither priced nor policy-checked" form rather than a claim the
hook cannot back. Every mechanical guarantee is unchanged: allow path
only, once per session via the exclusive marker, silence with no
session_id, never in --local, never with ACP_KEY or a proxy base URL.

Tests: wording assertions updated; new case for a payload with no (or a
missing) transcript; transcript-model-usage.test.mjs runs as a launched
session so its PreToolUse assertion stays about the body. 220/220.
davidcrowe added a commit that referenced this pull request Sep 16, 2026
…-checked (0.19.0) (#37)

* Tell plain-launch sessions their model calls are not priced or policy-checked (0.19.0)

Production 2026-09-16: 0 of 69 external tenants have a single llm.proxy.*
row, and every external harness tenant has zero costed runs. Everyone runs
the plain binary; nobody runs <harness>-acp, and nothing tells them — the
only surface that mentions cost is the launcher's own exit line, which
only prints after you already used the launcher.

The hook runs under the plain binary, so the hook says it. Port of the
installer's bundled-engine change (agenticcontrolplane.com PR #178) into
the govern.mjs that online installs fetch and the marketplace plugin runs
— #178 alone reaches nobody in today's data. One systemMessage, once per
session, on the first ALLOWED call:

  [ACP] Tool calls in this session are checked and logged. Model calls are
  not: plain `claude` sends them straight to the provider, so they are
  neither priced nor policy-checked (tool-result redaction, model routing).
  For the cost X-ray and model-call policy, launch with `claude-acp`
  (~/.acp/bin/claude-acp). Shown once per session.

- Detection: every launcher is generated from one template whose first act
  is `export ACP_KEY`; the hook inherits that env. No ACP_KEY, and no
  provider base URL at the ACP proxy, means the session was not started by
  a launcher. Bias to silence on doubt.
- Once per session: marker ~/.acp/session-notices/unpriced-<session_id>
  (id sanitised to [A-Za-z0-9._-], capped at 120), created with an
  exclusive flag so parallel hook processes for the same session cannot
  both fire. No session_id in the payload means silence, no marker.
  Markers older than 7 days are pruned; the directory is capped at 200.
- Every harness with a launcher is named by ACP_CLIENT (claude-acp,
  codex-acp for both "codex" and "codex-plugin", qwen-acp, opencode-acp,
  pi-acp, hermes-acp, prime-acp, grok-acp, dsh-acp). Cursor has no
  launcher and gets nothing.
- Marketplace-only installs (`claude plugin install`) have this hook but no
  launcher on disk; for them the notice names the installer instead of a
  path that does not exist. One deviation from #178, in favour of saying
  something true.
- Cloud mode only: the local path never reaches it. No-credential and
  managed-unenrolled paths never reach it either.
- Purely additive: rides the allow path as one JSON object (merged with the
  tier notice and the wire warning when they apply — the updatedInput
  branch now reads the message once instead of twice), never a deny or an
  ask, and the whole thing is wrapped so any failure is silence.
- Version 0.19.0 so the attestation hash is compared against a fresh
  version. Branched off main independently of the transcript-usage PR
  (0.18.0); the two conflict in bin/govern.mjs and this one rebases.

Tests (test/unpriced-session-notice.test.mjs, real hook against a stub
gateway): notice on a plain launch with nothing that could change the
call; silent on the 2nd/3rd call of the same session; fires once for a new
session; nothing without a session id; nothing with ACP_KEY in env or a
provider base URL at the proxy; deny and ask byte-identical with no
notice, then the notice rides the session's first allowed call; wire
warning + notice as ONE object; codex names codex-acp, cursor gets
nothing; six parallel processes yield exactly one notice; stale markers
pruned and the directory capped; hostile session ids sanitised; launcher
absent names the installer; --local silent. wire-warning.test.mjs now
runs as a launched session so its assertions stay about the warning.

* Notice: say what a plain-launch session HAS after 0.18.0, then the policy half it lacks

#36 made plain `claude` priced — from the transcript, at API-rate
equivalents — so "neither priced nor policy-checked" became false the
moment it merged. Rewritten to lead with what the session has and name
only what still needs the launcher:

  [ACP] Tool calls in this session are checked and logged, and model-call
  cost is estimated from the session transcript (API-rate equivalent, not
  a metered charge). Model calls are not policy-checked: plain `claude`
  sends them straight to the provider, so tool-result redaction and model
  routing are off. For those, launch with `claude-acp`
  (~/.acp/bin/claude-acp). Shown once per session.

The estimate-vs-metered distinction is stated up front so it does not
arrive later as a support question. The wording is chosen by the same
condition that makes the session priced — a readable transcript_path on
the payload — so a harness that hands over no transcript still gets the
plainer "neither priced nor policy-checked" form rather than a claim the
hook cannot back. Every mechanical guarantee is unchanged: allow path
only, once per session via the exclusive marker, silence with no
session_id, never in --local, never with ACP_KEY or a proxy base URL.

Tests: wording assertions updated; new case for a payload with no (or a
missing) transcript; transcript-model-usage.test.mjs runs as a launched
session so its PreToolUse assertion stays about the body. 220/220.
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