Implementation — usage.recorded events (billing backstop)
Issue BacMR #178 (rag half). Companion: gateway PR #180 consumes the event.
What was built
app/events/publisher.py—UsagePublisher(lazy aio-pika connection to the sharedbacmr.eventstopic exchange) andemit_usage_recorded(...), a best-effort helper that publishesusage.recorded { user_id, tokens, request_id, reason }. It no-ops whenRABBITMQ_URLis unset,tokens <= 0, orrequest_idis missing, and never raises into the caller.close_usage_publisher()runs on FastAPI shutdown.app/api/routers/chat.py— adopts the gateway's forwarded id (request_id = get_request_id() or uuid4()).agent_streaming_generatornow finalizes and publishes via a guardedfinalize_and_publish()helper, called on normal completion and in anexcept (GeneratorExit, asyncio.CancelledError)branch so a mid-stream client disconnect still bills for what was generated. Internal errors still do not finalize/bill.app/api/routers/quiz.py— publishesusage.recorded {reason:'quiz'}after a successful generate (already adoptedget_request_id()).
Why
The gateway meters from the sync response; a chat client disconnecting before the SSE usage
event flushes drops the charge. The out-of-band event closes that leak. The gateway's
wallet_charge_outbox dedupes on request_id, so the sync charge and this event can't
double-count — which is why chat now adopts the forwarded X-Request-ID.
Verification
pytest— 246 passed. New: publisher guard/no-op/publish/error-swallow; chat generator emits once on completion, once onaclose()(disconnect), and not on internal error; quiz emits withreason:'quiz'. Updated one pre-existing streaming test whoseaclose()now bills.ruff check .clean.mkdocs build --strictclean.- Not exercised against a live broker (frugal test env has none); covered by unit tests.