Billing & Cost Guardrails
Chinese version: billing-and-cost-guardrails.zh.md (this English version is authoritative).
Canonical reference for how a client is charged, how payment failure gates access, what cost is visible, and what actually constrains usage/cost on the platform. Cross-referenced against
api/src/billing/,api/src/conversations/, and the ops UI as of 2026-06-24. Terminology follows the repositoryGLOSSARY.md(Specialist = AI persona, Expert = HP human reviewer, AM = Account Manager, client = org). See also pricing strategy decision and ADR-020 (cost rolls up byosa_id).
TL;DR
- Pricing = flat monthly fee per assigned Specialist instance. No usage-based billing.
- The only things that hard-block activity are: a per-org message rate limit (abuse protection, not cost) and non-payment suspension (dunning). Neither is tied to cost.
- Optional human Expert correction/follow-up work is currently neither metered, priced, nor capped. LLM token cost is now visible to ops but not enforced. There is no per-client cost cap and no margin guardrail.
1. Billing model (how money is charged)
Unit of billing: one flat monthly fee per assigned Specialist instance (per org_specialist_assignment / OSA).
- Each
(Org × Specialist)assignment carries a rate inorg_specialist_assignment.monthly_rate_config.base_rateand maps to one Lago subscription.- Provider: Lago (self-hosted). Stripe is the payment processor (PSP) behind Lago, not a billing rail.
- Subscription created when an AM assigns the Specialist (
SyncSpecialistSubscriptionCommand); seeapi/src/billing/application/commands/sync-specialist-subscription.command.ts.
- One invoice per org, with one line item per Specialist. Proration is by day; mid-period rate changes prorate automatically (
api/src/billing/application/line-item-calculator.service.ts). - Rate changes are versioned in
org_specialist_assignment_rate_historyand trigger a Lago upgrade/downgrade. - Reuse multiplies revenue, not dilutes it: the same catalog archetype assigned to N clients = N independent operational instances = N separate monthly fees (each its own OSA + subscription).
Not billed: LLM token cost, message volume, conversation count, Expert time. The monthly fee is independent of usage — there are no plan tiers, no included quota, no metered overage. (The legacy Stripe plan-tier model was removed; see the pricing-strategy decision doc.)
Key code: api/src/billing/application/assignment-billing-input.resolver.ts, .../queries/ops-billing-overview.query.ts, api/src/billing/infrastructure/providers/lago.provider.ts.
2. Payment & access gate (dunning)
- Payment method: card or invoice; the
billingorg role can update the billing email (@OrgRoles("admin","owner","billing"),api/src/billing/interface/client-billing.controller.ts). - The only hard access gate is non-payment. On payment failure an org enters a grace period (default N days); once it elapses,
BillingAccessGateService.runGate()sets the org status tosuspended(api/src/billing/access-gate.service.ts). This gates on payment status, not on usage or cost. - Payment-failure notifications go to
owner/admin/billingmembers (#1475,api/src/billing/infrastructure/billing-payment-failed.notifier.ts).
3. Cost visibility (observe — does NOT enforce)
SuperAdmin-only panels that surface cost/effort but apply no limits:
| What | Where | Source |
|---|---|---|
| Per-org LLM cost (total + per-Specialist) | /ops/billing?org=<id> | LlmCostRollupService over the unified llm_runs ledger grouped by osa_id (#3194 / PR #3195; read source cut over from messages.cost_cents in #4799) |
| Platform LLM cost (trend, top orgs, by Specialist, by purpose) | /ops/billing (SuperAdmin section) | PlatformCostStatsService over llm_runs (#4797) + nightly Langfuse↔DB reconciliation badge (#4798) |
| Per-Specialist Expert workload (items handled + handle time) | /ops/billing?org=<id> | resolved queue items → conversation → OSA (#3196 / PR #3200) |
| Expert leverage (clients & Specialist instances per Expert) | /ops/analytics | expert_access (ADR-007) (#3197 / PR #3199, merged) |
LLM cost is stored per call in llm_runs (micro-USD accurate; the legacy messages.cost_cents/token_input/token_output/model_name columns keep being written for history but are no longer read — #4799) and rolled up per OSA, but is not added to the invoice — it's audit/analytics only.
4. Usage & cost guardrails (what actually constrains anything)
"Hard-blocks?" = does it actually stop activity. "Observable?" = can ops see the underlying signal even if nothing enforces it — the gap between "can't enforce" and "can't even see it" is the sharp one for pricing decisions.
| Guardrail | Rule | Hard-blocks? | Observable? | Dimension |
|---|---|---|---|---|
| Message rate limit | Per-org org_message_quota, default 1000 msgs/hour → HTTP 429 (conversations.service.ts, migration 1714000000007-OrgQuota.ts) | ✅ Yes | ✅ Yes — hourly/daily usage on /ops/billing?org=<id> Org Usage | Throughput (abuse protection — not cost) |
| Non-payment suspension | Grace period after payment failure → org suspended | ✅ Yes | ✅ Yes — payment-failure / dunning state on /ops/billing (#1433/#1551) | Payment status (not cost) |
| Agent delivery | Successful Hermes replies deliver directly; legacy confidence/review thresholds are inert | ❌ No content gate | ✅ Yes — turn latency, model/tool usage, and delivery outcome are observable | Delivery |
| Per-client LLM cost cap | none | ❌ | ✅ Yes — per-org & per-Specialist LLM cost on /ops/billing?org=<id> (#3195) | Cost (visible, uncapped) |
| Optional Expert follow-up cost | none (after-delivery observation/correction is unmetered and uncapped) | ❌ | ⚠️ Partial — workload (items + handle time) visible (#3200); dollarized labour cost is not (#3196) | Cost (volume-visible only) |
| Usage-based / overage billing | none | ❌ | ✅ Yes — message usage is visible (rate-limit panel), but never billed | Billing |
| Margin guardrail / alerting | none enforced (SA-B5 visibility only, partial) | ❌ | ❌ No — cost is visible but margin (cost vs revenue) is not computed anywhere | Margin |
5. Why this matters (the open risk)
Because pricing is flat per Specialist with unlimited usage, a heavy client's model/tool usage and optional Expert follow-up can rise while the monthly fee stays fixed. Ops still needs a margin signal; delivery must not be blocked as a cost-control mechanism. LLM cost and Expert workload are visible (§3), but:
- Expert labour is still not dollarized (no active-review-time instrumentation, no per-Expert cost rate) — tracked in #3196.
- No per-client cost cap, no overage, no margin alert. SA-B5 (per-org gross margin) is partial; SA-E4 (leverage) shipped.
If/when flat-fee-unlimited is offered contractually, the safe-making additions are: a per-client cost/usage soft cap + alert, an Expert-labour cost model, and a margin dashboard (the rest of SA-B5).
6. Cost statistics (planned)
Platform / client(org) / Specialist-level LLM cost statistics are planned in docs/specs/llm-cost-statistics.md (approved 2026-07-23). Its four standing decisions, recorded here as design-of-record:
- Langfuse = exploration + cross-check analytics; own DB = billing-grade numbers (the
api/src/llm/implementation and this spec are authoritative for retention, availability, PII, and provenance). - Specialist becomes a first-class Langfuse dimension via value-bearing tags (
specialist:<id>,source:agent|api);user_idstaysorg_id,session_idstays conversation UUID. - Ledger unification — CUT OVER (#4799, 2026-07-23): Hermes chat-turn cost dual-writes into
llm_runs(purpose='chat-hermes', micro-USD computed server-side from tokens × pricing); all cost READS (rollup + daily budget gate) now come fromllm_runs;messages.cost_centsstays written for history only. The two-week dual-run observation gate was waived by the product owner — the old ledger was never formally used or accepted. 90-day backfill viaapi/scripts/backfill-llm-runs-from-messages.ts(run once per environment). - Nightly Langfuse↔DB reconciliation with drift alert (>5% or >$1/day), alert-only.
6.1 Runbook: LLM cost via Langfuse (Phase 0 — #4790)
Until the in-product ops views ship (Phase 2, #4797), ops answer cost questions against the self-hosted Langfuse (https://langfuse.hptestingsite.com, project humanwork-dev). All queries below were verified against Langfuse v3.174.1 on 2026-07-23. Auth: HTTP Basic with the project's pk-lf-…:sk-lf-… key pair (in the Railway api service env as LANGFUSE_PUBLIC_KEY/LANGFUSE_SECRET_KEY); send a browser-like User-Agent (the Cloudflare fronting this host 403s bot agents, error 1010).
"What did the platform spend, per day?" — daily metrics endpoint (also breaks down by model):
GET /api/public/metrics/daily?limit=30
"Top-N orgs by spend this week?" — Metrics API, group by user (= org id). NOTE: the Metrics API is GET with a URL-encoded query JSON param (POST returns 405):
GET /api/public/metrics?query={"view":"traces","metrics":[{"measure":"totalCost","aggregation":"sum"}],"dimensions":[{"field":"userId"}],"fromTimestamp":"<ISO>","toTimestamp":"<ISO>"}
"Cost of one conversation?" — same query with "dimensions":[{"field":"sessionId"}] (session = platform conversation UUID), or open the trace in the UI via Tracing → Sessions.
"Cost of one Specialist?" — filter (not group) by trace metadata:
"filters":[{"column":"metadata","operator":"=","key":"specialist_id","value":"<specialist uuid>","type":"stringObject"}]
Grouping across specialists in one query is not possible pre-#4792 (see capability matrix in the spec's spike findings); loop the filter over known specialist ids, or wait for value-bearing tags.
UI dashboards (optional, ~10 min manual setup — Langfuse 3.174.1 has no public dashboards API, so these are created once by hand): in the project, Dashboards → New dashboard → add three widgets: (1) Platform daily cost — view Traces, metric Total cost (sum), no breakdown, time granularity day, chart Line; (2) Cost by org — same metric, breakdown dimension User, chart Bar (horizontal), top 10; (3) Trace count by day — metric Count, granularity day. Session drill-down needs no widget — use Tracing → Sessions sorted by Total cost.
Caveats (read before quoting numbers)
- Per-org Langfuse numbers UNDERCOUNT until #4793 ships: API-side
LlmServicecalls (summaries, subjects, classify, embeddings, transcripts) carry nouser_id, so they are invisible in any per-user/org grouping — only Hermes chat turns are attributed. TheuserId: nullrow in group-by-user results is exactly that unattributed spend. LlmService.stream()is unmetered and untraced (OD-2 in the spec).- Expert-consultation turns carry no cost in the DB ledger (
cost_usd = Noneagent-side); their Langfuse traces do carry inferred generation cost — another source of small Langfuse-vs-DB drift until Phase 2 unification. - These are analytics numbers, not billing numbers (decision D1): billing-grade queries stay on the DB ledger.
Source map
| Concern | File(s) |
|---|---|
| Pricing / subscription sync | api/src/billing/application/commands/sync-specialist-subscription.command.ts, .../assignment-billing-input.resolver.ts, infrastructure/providers/lago.provider.ts |
| Invoice line items / proration | api/src/billing/application/line-item-calculator.service.ts |
| Access gate (dunning) | api/src/billing/access-gate.service.ts |
| Payment-failure notify | api/src/billing/infrastructure/billing-payment-failed.notifier.ts |
| LLM cost rollup | api/src/billing/llm-cost-rollup.service.ts, .../queries/org-llm-cost.query.ts |
| Expert workload | api/src/billing/application/queries/org-expert-workload.query.ts |
| Expert leverage | api/src/expert-access/expert-leverage.query.ts |
| Message rate limit | api/src/conversations/conversations.service.ts, api/migrations/1714000000007-OrgQuota.ts |
Related
- User stories: SA-B5 (per-org cost / gross margin), SA-E4 (expert leverage) —
docs/user-stories.md. - Status:
docs/implementation-status.md. - Issues/PRs: #3194/#3195 (LLM cost), #3196/#3200 (Expert workload), #3197/#3199 (leverage, merged).