feat(slack): track the Slack bot's AI token usage for billing - #281
feat(slack): track the Slack bot's AI token usage for billing#281JeremyFunk wants to merge 2 commits into
Conversation
The Slack bot runs its own model (Cloudflare Workers AI) on Railway, outside
every path the Maple API already meters, so Slack-driven AI spend was invisible
to billing entirely.
The bot reports usage; the API bills it. eve surfaces provider-reported token
counts on exactly one hook event — `step.completed`, one per model call
(`turn.completed` carries only `{ sequence, turnId }`) — so the new
`agent/hooks/token-usage.ts` reads it there and POSTs to a new internal
endpoint, `POST /internal/slack/workspaces/:teamId/usage`, guarded by the same
dedicated `SLACK_INTERNAL_SERVICE_TOKEN` bearer as resolve/revoke. The API
resolves the team's org and calls `trackTokenUsage` with a new `slack-agent`
source tag.
Autumn's credential deliberately stays on the API side: `AUTUMN_SECRET_KEY` is
org-agnostic, so holding it would mean being able to write usage against any
customer. The bot only ever proves it is the bot. No new env vars — this reuses
`MAPLE_API_BASE_URL` + `MAPLE_INTERNAL_SERVICE_TOKEN`, which the bot already
needs for workspace resolution.
Reported per step rather than accumulated per turn: batching would need mutable
state keyed by turn id, which leaks on a turn that never terminates and drops
the whole turn's spend if the process dies mid-turn. `<sessionId>:<turnId>:<stepIndex>`
is naturally unique, so it doubles as the idempotency key and a replayed durable
step de-duplicates itself. The API namespaces it by team so two workspaces can
never collide.
Fire-and-forget throughout: `trackStepUsage` never rejects, the endpoint answers
202 even when Autumn fails, and org lookup avoids `resolveForBot` (new
`resolveOrgForTeam`) so attributing a token count never decrypts the workspace's
bot token and API key.
Zero-token steps are skipped — `workers-ai-provider` reports all-zeros rather
than `undefined` when a stream is truncated before the usage chunk arrives.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Your Pullfrog Router balance is exhausted. You have a payment method on file but auto-reload is disabled, so runs paused once your balance went past the overdraft buffer. Top up balance → · Enable auto-reload →
|
🍁 Maple PR previewWarning The latest preview deployment did not complete. The Alchemy deploy outcome was Commit |
Six review findings on the Slack AI usage path.
**Idempotency key was not unique per model call (silent under-billing).**
`<sessionId>:<turnId>:<stepIndex>` looked unique but is not: eve derives
`turnId` as `turn_${sequence}` and only advances `sequence` in
`emitTurnEpilogue` / `emitRecoverableFailedTurn`. A *terminal* model failure
(provider 400, context-length, model-config error) runs `emitFailedStep`, which
advances nothing — so the user's re-ask mints the same `turn_${sequence}` with
`stepIndex` back at 0 and that retry's step 0, usually the largest input step,
reuses a key Autumn has already seen. It is dropped with no log and no 4xx.
eve's park-and-resume branches have the same shape. The key now keeps the
readable prefix and appends a per-process boot id + counter. Trade-off taken
deliberately and documented: a genuine duplicate *delivery* would double-count,
but the bot issues a single un-retried `fetch`, so that is near-impossible —
whereas the collision above is a live path.
**No ceiling on a financial write.** `inputTokens`/`outputTokens` accepted any
non-negative int, on a shared service bearer that works against every org with
no rate limiting, so one POST of `1e15` would blow a customer's plan limit and
invoice. Capped at 8x the bot's 262,144-token context window; over-cap is 400.
**A persistent Autumn outage was invisible.** `postTrack` swallowed everything
into `console.warn`, so the route's `catchCause` was dead code and every request
answered `202 {"tracked": true}` with an Ok span even when nothing was billed —
a rotated `AUTUMN_SECRET_KEY` (bound with `optionalSecret()`, which silently
omits a key missing from an Infisical environment) could unbill every Slack turn
for a week with nobody paged. `trackTokenUsage` now returns an outcome; the
route logs an error, annotates the span, and reports `tracked`/`outcome` in the
body. Still 202 and still never blocking or retried — the Slack reply has
already gone out. The `triage` caller ignores the additive return value.
The test asserting this path asserted nothing (the 500 stub was swallowed); it
now asserts both writes were attempted and the response no longer claims
success, plus a new case for the unconfigured-credential path.
Also: `logAccess` labels lines by route, so a 401 on `usage` no longer reads as
`resolve` traffic when triaging a billing anomaly; the Autumn fetch takes a 5s
`AbortSignal.timeout`, since it is awaited inline and a stall otherwise ties up
a Worker request per model step; and zero-token skips emit a throttled
diagnostic, so "the provider never reports usage and we bill exactly nothing"
is detectable from logs rather than perfectly silent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Your Pullfrog Router balance is exhausted. You have a payment method on file but auto-reload is disabled, so runs paused once your balance went past the overdraft buffer. Top up balance → · Enable auto-reload →
|

The Slack bot runs its own model (Cloudflare Workers AI, on Railway) outside every path the Maple API already meters, so Slack-driven AI spend was invisible to billing entirely — no Autumn events, no persisted token counts, nothing.
Architecture
The bot reports usage; the API bills it. Same split as chat-flue →
AiTriageWorkflow.Where usage is captured. eve exposes provider-reported token counts on exactly one hook event:
step.completed, one per model call, carryingusage?: { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, costUsd }.turn.completedcarries only{ sequence, turnId }, andHookContexthas no usage accounting (eve's running totals live in a private harness module with no public export).workers-ai-providerpopulates AI SDK v7 usage in bothdoGenerateanddoStream, so the counts are real.Auth. Reuses the dedicated
SLACK_INTERNAL_SERVICE_TOKENbearer that already guards resolve/revoke — deliberately not the sharedINTERNAL_SERVICE_TOKEN, matching the existing endpoints. Autumn's credential stays on the API side:AUTUMN_SECRET_KEYis org-agnostic, so a bot holding it could write usage against any customer. The bot only ever proves it is the bot; the API decides whose spend it is.Per step, not per turn. Per-turn batching means mutable state keyed by turn id, which leaks on a turn that never terminates and drops the whole turn's spend if the process dies mid-turn.
<sessionId>:<turnId>:<stepIndex>is naturally unique, so it doubles as the idempotency key and a replayed durable step de-duplicates itself. The API namespaces it by team (slack:<teamId>:<key>) so two workspaces can never collide, andtrackTokenUsageappends:slack-agent:input|outputper its existing convention.Nothing here can cost a user their reply.
trackStepUsagenever rejects and is fired un-awaited from the hook; the endpoint answers202even when Autumn 500s (the Slack reply already went out — a 5xx would only buy a retry of something Autumn may already have recorded); failures are logged as structured, queryable records viaemitAgentLog/Effect.logWarning.Least privilege on lookup. New
resolveOrgForTeamselects only the org column, rather than reusingresolveForBot— attributing a token count must not decrypt (or risk logging) the workspace's bot token and full-access Maple API key.Skipped, deliberately: zero-token steps (
workers-ai-providerreports all-zeros rather thanundefinedwhen a stream is truncated before the usage chunk arrives, so these are expected noise), sessions with no Slack team (local dev — no org to bill), and the self-hosted default org (existingtrackTokenUsagebehaviour).New env vars / secrets
None. The bot side reuses
MAPLE_API_BASE_URL+MAPLE_INTERNAL_SERVICE_TOKEN, which it already needs for workspace resolution; the API side reusesSLACK_INTERNAL_SERVICE_TOKEN,AUTUMN_SECRET_KEYandAUTUMN_API_URL. No migration — usage goes straight to Autumn, as with the existing triage call sites.Behaviour if unconfigured is the pre-existing shape: without
AUTUMN_SECRET_KEYthe API accepts and no-ops; withoutSLACK_INTERNAL_SERVICE_TOKENthe endpoint 401s.Testing
apps/api— 8 new route tests inslack-integration.http.test.ts(PGlite + a stubbed Autumn endpoint): org attribution and exact idempotency keys, zero-token side skipped, 202-on-Autumn-failure, default-org exemption, 404 on unknown/revoked team, bearer enforcement (including that the shared internal token is rejected), 401 when unconfigured, 400 on 7 malformed bodies. Full suite 1204 passed | 5 skipped.apps/slack-agent— newtoken-usage.test.ts(report shape, model stamping, skip cases, count coercion, failure swallowing) + 3 newreportTokenUsagecases inmaple.test.ts. 44 pass across both files.bun typecheck— 33/33 turbo tasks green;apps/slack-agenttscclean (it is outside the root workspace, so it is typechecked separately).main:apps/webalerts/timezone suites (SchemaError: Missing key at ["environments"]), and theapps/slack-agentCannot find module 'eve/tools'resolution errors.Follow-up, not in scope
Subagent spend is reported separately by eve (on
action.resultwithkind: "subagent-result") and is not captured here — this agent defines no subagents today, so there is nothing to miss yet. Cache read/write tokens are carried by eve but dropped, since Autumn only metersai_input_tokens/ai_output_tokens.🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.Review fixes
Two adversarial reviews found six confirmed issues on this path; all are fixed in
654f360b.HIGH — the idempotency key was not unique per model call → silent under-billing.
<sessionId>:<turnId>:<stepIndex>looked unique but is not. eve derivesturnIdasturn_${sequence}and advancessequenceonly inemitTurnEpilogue/emitRecoverableFailedTurn.A terminal model failure (provider 400, context-length, model-config error) runs
emitFailedStep, which advances nothing — so when the user re-asks, the next turn is minted withthe same
turn_${sequence}andstepIndexback at 0, and that retry's step 0 (usually thelargest input step) reuses a key Autumn has already seen. Autumn drops it silently: no log, no 4xx,
just lost revenue. eve's park-and-resume branches (
setPendingRuntimeActionBatch,setPendingAuthorization) return emission state unchanged and have the same shape.The key now keeps the readable
<sessionId>:<turnId>:<stepIndex>prefix and appends a per-processboot id + monotonic counter. Trade-off, taken deliberately and documented in the source: a
genuine duplicate delivery of one call would now be billed twice — but the bot issues a single
fetchwith no retry, so that is near-impossible, whereas the collision above is a live path.MEDIUM — no upper bound on a financial write. Token counts were validated as any non-negative
int, on a shared service bearer that works against every org, on a plain
HttpRouterwith no ratelimiting. One POST of
1e15would land on a customer's Autumn meter and blow their plan limit andinvoice. Capped at 8x the bot's 262,144-token context window; over-cap → 400.
MEDIUM — a persistent Autumn outage was invisible.
postTrackswallowed every failure intoconsole.warn, so the route'sEffect.catchCausewas dead code: every request answered202 {"tracked": true}with an Ok span even when nothing was billed. A rotatedAUTUMN_SECRET_KEY— bound with
optionalSecret(), which silently omits a key missing from an Infisical environmentwith no CI error — could unbill every Slack turn for a week with nobody paged.
trackTokenUsagenow returns an outcome; the route emits a structured
logError, annotates the span(
maple.billing.*), and the body reports{ tracked, outcome }instead of asserting success.Still 202, still never blocking or retried — the Slack reply has already gone out. The
triagecaller ignores the additive return value.
MEDIUM — the billing-failure test asserted nothing. Because of the above, the 500 stub was
swallowed and the test would have passed with the entire error branch deleted. It now asserts both
writes were attempted and that the response no longer claims success, plus a new case covering the
unconfigured-credential (
no-credentials) path.LOW — ops signal mislabeled.
logAccesshardcoded "Slack internal resolve …", so usagerequests (including 401s on the usage route) logged as resolve traffic. Lines are now labeled by
the route that produced them.
LOW — no timeout on the Autumn call, which
handleUsageawaits inline. At one request per modelstep, a stalled Autumn tied up a Worker request per step. Added a 5s
AbortSignal.timeout.LOW — silent zero-token skips. Zero-token steps are skipped as truncation noise; if
workers-ai-providernever surfaces usage for the configured model, that means billing exactlynothing with no error anywhere. Skips now emit a throttled
usage_report_zero_tokenswarning(first occurrence, then at most one per 5 min with a cumulative count). Zeros are still not billed.
Verified correct by both reviews and left alone: the auth (constant-time,
maple_svc_prefix,fails closed, no fallback to the shared token), per-step usage being incremental not cumulative
(so there is no quadratic over-billing), cached/reasoning tokens already being included, the
un-awaited hook firing, and env plumbing.
bun typecheckgreen (33/33; the pre-existingalchemy.run.ts(148)Worker type error also existson
main).bun run testgreen except the failures already present onmain. slack-agent:typecheck clean, 189/189.
Known gaps (out of scope here — flagging for the record)
harness/compaction.jscallsgenerateTextdirectly and emits no step event and no usage, so context-compaction tokens are invisible to
this path. Not fixable without upstream eve support.
step.completed, so their spend isnever reported either.
workers-ai-providercalls/ai/run/{model}with{stream:true}and never setsstream_options.include_usage. If no chunk carries usage, every step reports all-zeros and thisfeature bills nothing. Needs one real staging Slack turn to confirm — the new
usage_report_zero_tokenswarning (fix 7 above) is the detector for exactly this.