Skip to main content

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 repository GLOSSARY.md (Specialist = AI persona, Expert = HP human reviewer, AM = Account Manager, client = org). See also pricing strategy decision and ADR-020 (cost rolls up by osa_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 in org_specialist_assignment.monthly_rate_config.base_rate and 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); see api/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_history and 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 billing org 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 to suspended (api/src/billing/access-gate.service.ts). This gates on payment status, not on usage or cost.
  • Payment-failure notifications go to owner / admin / billing members (#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:

WhatWhereSource
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/analyticsexpert_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.

GuardrailRuleHard-blocks?Observable?Dimension
Message rate limitPer-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 UsageThroughput (abuse protection — not cost)
Non-payment suspensionGrace period after payment failure → org suspended✅ Yes✅ Yes — payment-failure / dunning state on /ops/billing (#1433/#1551)Payment status (not cost)
Agent deliverySuccessful Hermes replies deliver directly; legacy confidence/review thresholds are inert❌ No content gate✅ Yes — turn latency, model/tool usage, and delivery outcome are observableDelivery
Per-client LLM cost capnone✅ Yes — per-org & per-Specialist LLM cost on /ops/billing?org=<id> (#3195)Cost (visible, uncapped)
Optional Expert follow-up costnone (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 billingnone✅ Yes — message usage is visible (rate-limit panel), but never billedBilling
Margin guardrail / alertingnone enforced (SA-B5 visibility only, partial)❌ No — cost is visible but margin (cost vs revenue) is not computed anywhereMargin

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:

  1. 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).
  2. Specialist becomes a first-class Langfuse dimension via value-bearing tags (specialist:<id>, source:agent|api); user_id stays org_id, session_id stays conversation UUID.
  3. 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 from llm_runs; messages.cost_cents stays 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 via api/scripts/backfill-llm-runs-from-messages.ts (run once per environment).
  4. 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 LlmService calls (summaries, subjects, classify, embeddings, transcripts) carry no user_id, so they are invisible in any per-user/org grouping — only Hermes chat turns are attributed. The userId: null row 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 = None agent-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

ConcernFile(s)
Pricing / subscription syncapi/src/billing/application/commands/sync-specialist-subscription.command.ts, .../assignment-billing-input.resolver.ts, infrastructure/providers/lago.provider.ts
Invoice line items / prorationapi/src/billing/application/line-item-calculator.service.ts
Access gate (dunning)api/src/billing/access-gate.service.ts
Payment-failure notifyapi/src/billing/infrastructure/billing-payment-failed.notifier.ts
LLM cost rollupapi/src/billing/llm-cost-rollup.service.ts, .../queries/org-llm-cost.query.ts
Expert workloadapi/src/billing/application/queries/org-expert-workload.query.ts
Expert leverageapi/src/expert-access/expert-leverage.query.ts
Message rate limitapi/src/conversations/conversations.service.ts, api/migrations/1714000000007-OrgQuota.ts
  • 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).