| name | heater-appb-bubba-ai |
| description | Use when working on Bubba, HEATER's AI assistant — /api/chat/* endpoints, provider streaming (src/ai/providers.py), token metering and spend caps, AI provider keys, attachments/page-context, saved prompts, or the chat composer in web/src/components/bubba/. |
Bubba (AI Assistant) — streaming, metering, and the byte-identical drain
Bubba is read-only on the league — it recommends, never writes. Its explainer tools are additive and registry-first (src/ai/tools.py over CONSTANTS_REGISTRY); never give it a mutating tool without an explicit owner decision.
The one inviolable seam
src/ai/providers.py is shared with the live Streamlit app. _chat_events() (providers.py:46-166) is the streaming generator; chat() (169-200) is a thin DRAIN over it that must return the byte-identical pre-streaming dict. Frozen-reference guard: tests/api/test_ai_providers_streaming.py:79 test_chat_is_a_drain_returning_todays_exact_dict — if your change makes it fail, the live Streamlit app just changed; redesign additively.
Failure story: litellm.stream_chunk_builder returns None on an empty stream — undiscovered, None.choices would have crashed the LIVE app's chat() hot path via the shared drain. Guarded at providers.py:95-103 (graceful done). When touching the stream loop, preserve that guard and the terminal-done contract.
Money flow (metering & caps)
ChatService.send (api/services/chat_service.py): key check → cap check → call → meter BEFORE persist (~line 162) so history failures can't skip billing. send_stream meters server-side before emitting the terminal done frame — a tail-disconnect still bills. Mid-stream partial-token metering was a documented under-count until P5 added it (litellm constraint: Py<3.14 for ≥1.83.8; CI runs 3.12).
- Caps:
src/ai/budget.is_over_cap(user_id, on_own_key, cap_usd=None) (budget.py:72-76) — the cap_usd override is the tier hook; own-key users are exempt. api/gating.py::get_managed_ai_cap resolves tier→cap, returns None while billing dormant (= admin default) and NEVER raises (store failure → bounded, logged over-grant). Over-cap streaming error carries code:"over_cap" → frontend OverCapNudge (Bubba.tsx ~841). Chat identity offset: chat_user_id = 1_000_000_000 + AppUser.id.
- Keys:
src/ai/keys.py — per-provider env HEATER_AI_ADMIN_KEY_<PROVIDER> (keys.py:27-33); resolution user-own-key → admin key → None (124-142); Fernet key HEATER_AI_KEY (falls back to HEATER_RELAY_KEY). Key VALUES never in git.
Message construction
ChatService._build_user_content (chat_service.py:60-89): with NO tag/image/page-context it returns the bare message string — that exact byte-identity is what keeps the equivalence test green; attachments produce the OpenAI multimodal parts list; page_context folds a data-json block into the user turn. Vision-incapable model + image → graceful error frame, never a 500.
Frontend composer (web/src/components/bubba/)
- SSE reader
bubba.sendStream handles text_delta / tool_started / tool_result / done / error (Bubba.tsx:380-436). Errors surface as an in-chat frame — the stream endpoint never 500s mid-stream.
- Page context auto-attach:
usePageData publishes {pageId, data} to BubbaContext; Bubba serializes to a 16 KB UTF-8-byte cap, code-point-safe (Bubba.tsx:57). Wiring a page through usePageData gives Bubba context for free.
- Tag flow: capture-phase click reads
data-bubba-tag off PlayerLink (intercepts before PlayerDialog); data-bubba-panel excludes Bubba itself from snapshots; snapshots of cross-origin headshots render blank (documented html-to-image caveat, keyed on source==="snapshot").
- Message queue drains only after a CLEAN turn; an errored turn pauses the queue with a hint.
- PDFs attach as EXTRACTED TEXT (pdfjs, dynamic import, 12 KB cap) — works on every model; not raw-PDF passthrough.
When NOT to apply
Model/pricing questions → the claude-api skill. Adding a normal (non-chat) endpoint → heater-appb-endpoint-slice. Tier design changes (new tiers, cap amounts) are owner decisions.