Subscription-tier meter (Claude Code Pro + Codex on ChatGPT Plus)
Why we cannot enforce the dollar
Section titled “Why we cannot enforce the dollar”Claude Code Pro/Max and Codex on ChatGPT Plus/Pro use OAuth
subscriptions. Traffic still hits api.anthropic.com/v1/messages and
chatgpt.com/backend-api/codex/responses, so the proxy sees the calls
— but Anthropic and OpenAI settle quota against the flat fee
internally. If SpendGuard’s BYOK ledger charged each token at retail
price, it would invent a phantom dollar on top of the $20/mo plan.
Instead, the subscription_meter mode:
- Classifies each request as BYOK vs subscription before opening a ledger transaction.
- For subscription requests, runs a meter-only estimate (tokens ×
retail price ÷ 1M) and tags the audit row with
reservation_source = subscription_meter. - Skips both
ledger_entriesandreservationswrites — the meter row is the canonical record.
Three modes
Section titled “Three modes”| Mode | Default | Behaviour |
|-------------|:-------:|--------------------------------------------------------|
| meter | ✅ | Estimate only; no alert, no block. |
| soft_cap | | Emit subscription_soft_cap_alert at alert_at_atomic; CONTINUE. |
| hard_cap | | At hard_cap_at_atomic, short-circuit DENY + synthetic 429. |
The mode is per-tenant via the subscription_caps table (cap-aware
SQL coming in the next slice; today the sidecar reads thresholds from
DecisionRequest.runtime_metadata.subscription).
Classification
Section titled “Classification”The classifier requires both signals to match before flagging a request as subscription:
| Signal | Claude Code Pro | Codex / ChatGPT-OAuth |
|--------|------------------|------------------------|
| Authorization prefix | sk-ant-oat01-… | eyJ… (JWT header) |
| User-Agent | claude-cli/<ver> or claude-code/<ver> | codex_cli_rs/<ver> |
| BYOK distinguisher | sk-ant-api03-… | sk-proj-… / sk-… |
UA alone is forgeable (operators routinely use claude-cli with a
BYOK key); token alone is insufficient because OAuth tokens leak more
easily than the CLI binary. Both must match — see
classifier.rs.
Hard-cap synthetic 429
Section titled “Hard-cap synthetic 429”When hard_cap mode is active and the projected meter consumption
crosses hard_cap_at_atomic, the sidecar short-circuits before the
ledger:
{ "error": { "type": "rate_limit_exceeded", "message": "spendguard subscription cap reached", "code": "spendguard_subscription_cap" }}The shape is vendor-matched so claude-cli / codex_cli_rs treat
it identically to a vendor 429 and exit cleanly. The distinct
code = "spendguard_subscription_cap" lets operators distinguish
SpendGuard-injected from vendor-injected 429s in their dashboards.
Retry-After is bounded at 86 400 s (24 h) — protects CLIs from a
misconfigured cap window asking them to wait more than a day.
Migrations & schema
Section titled “Migrations & schema”D13 ships three additive migrations:
0044_subscription_meter.sql—subscription_meterstable +audit_outbox.reservation_sourcecolumn.0045_subscription_alerts.sql—subscription_alertstable with cooldown (default 1 h, mirrorsstats_aggregatordrift_alert).0046_subscription_importer.sql—subscription_import_jobstable +audit_outbox.import_sourcecolumn.
All three are RLS-enabled with the standard tenant-id isolation
policy. See
services/ledger/migrations/.
Importer stubs
Section titled “Importer stubs”The flat-fee meter is best-effort. To true it up against the vendor’s billing report, D13 ships stub importer crates that lock the contract for Day-2 reconciliation:
pub fn import_record_to_audit_row(rec: &ImportRecord) -> AuditRowDraft { AuditRowDraft { tenant_id: rec.tenant_id.clone(), reservation_source: "subscription_meter", import_source: rec.importer_kind.as_str(), .. }}D14 / D15 / D16 light up Devin / Manus / Genspark live importers. Anthropic Console Usage and OpenAI Admin Usage stay stubbed until their respective Admin APIs ship.
make -C deploy/demo demo-up DEMO_MODE=subscription_meterWalks three cap-decision scenarios (PASS / SOFT / HARD), asserts the
hard-cap response carries Retry-After ∈ [1, 86_400], and verifies
the load-bearing invariant: zero ledger_entries rows and
zero reservations rows under the meter tenant.
See deploy/demo/subscription_meter/README.md.