osr-financial-control-plane
Turn the hosted platform's billing into a financial control plane - usage metering with provider cost and customer charge side by side, a versioned pricing engine (provider price book, reseller markup, customer-specific and tiered pricing), an immutable credits wallet ledger, Stripe invoices and usage charges, provider finance (expected vs invoiced spend, reconciliation, settlement), revenue / cost / gross-margin analytics, finance and provider-manager operator roles and before/after audit. Use when adding reseller pricing, credits or prepaid balances, provider cost tracking, margin reports, provider settlement or finance RBAC to platform/api and the operator console, or when reasoning about how money flows through the platform.
- Package
- .claude/skills/osr-financial-control-plane
- Compatibility
- OpenSmartRoute repository, Python >= 3.11, Node >= 22, Stripe account (test mode first)
- License
- Apache-2.0
- Domains
- finance coding general
- Quality prior
- 0.85
- Tags
- opensmartroute platform billing pricing ledger credits margin provider settlement stripe finance
Install by copying .claude/skills/osr-financial-control-plane/ into the skills folder of your coding assistant. To load every package as a routing target: osr --skills .claude/skills route "..." --plan.
The platform already sells flat plans through Stripe (billing.py), meters every request (usage rows), knows
what each model costs upstream (models.py, RouteTarget.cost) and has an operator console. What it does
not do yet is connect those into one money flow:
Providers -> Models -> Price books -> Routing -> Usage -> Customer charge -> Ledger -> Invoice
-> Provider cost -> Provider ledger -> Settlement
-> Margin -> AnalyticsThe design principle is two independent price books per model: what the provider charges us
(provider cost) and what we charge the customer (customer price), with the pricing version pinned on every
metered row so gross margin = customer revenue - provider cost - fees is reproducible months later. Money
movement stays in Stripe; the platform owns metering, pricing, the ledger and the analytics.
Read .claude/skills/osr-platform/SKILL.md first for the platform conventions (routers per domain,
openapi.json regeneration, docs-claims gates, Postgres portability). This skill is the finance-specific
playbook on top of it.
What exists today (build on it, do not duplicate it)
| Concern | Where it lives now | What it gives you |
|---|---|---|
| Metering | db.record_usage(...) called from PlatformAPI.metered(...) (api.py) for route / chat / embeddings / audio / images; table usage (account_id, key_id, ts, day, endpoint, target_id, ok, status, latency_ms, tokens, cost_usd, request_id, model, domain, complexity, user_id, tenant), usage_daily (requests per day, the plan quota) | one row per metered request; tokens is the total, cost_usd is the provider cost estimate (ExecutionResult.outcomes[*].cost_usd, falling back to RouteTarget.estimated_cost(tokens) = usd_per_call + usd_per_1k_tokens * tokens / 1000) |
| Budgets | PlatformAPI.budgets(p, tenant) / check_budget (policy daily_budget_usd, monthly_budget_usd), db.spend, db.spend_by_tenant, alerts.budget_alerts | spend caps per workspace and tenant, 429 + X-Budget-* headers |
| Provider prices | models.LLM.input_usd_per_1m / output_usd_per_1m / cache_read_usd_per_1m (OpenRouter snapshot data/models.json, refreshed by rankings.py); SDK RouteTarget.cost (usd_per_1k_tokens, usd_per_call) in targets.yaml | list prices per model, the router's cost objective |
| Providers | providers.Provider (deployment-wide, PRESETS, probe() health check, ModelMapping target -> upstream model), workspace_providers (BYOK, AES-256-GCM credentials) | who executes which target, credentials, health |
| Plans and subscriptions | plans.Plan (price_usd_month/year, requests_per_month, overage_usd_per_100k, trial_days), billing.Billing (Checkout, in-place plan change, portal, invoices, cancel, webhook, sync_overage -> Stripe Billing Meter events, admin_router console API), coupons.py | recurring revenue, overage per 100k requests, coupon trials, the operator Billing page |
| One-time payments | Billing.listing_checkout_url (mode=payment, metadata[listing]) and Billing.on_purchase | the pattern for credit purchases |
| Analytics | admin_console.py /analytics (cost per day, per target, per workspace, per plan), reports.py digests, telemetry.py series, dashboard /savings (baseline_cost - routed_cost) | cost views without a revenue side |
| Audit and roles | db.record_audit(action, actor_type=, actor_id=, actor_label=, account_id=, **details), OPERATOR_ROLES = ("operator", "superadmin"), PlatformAPI.require_superadmin | who did what; only two operator roles |
Two gaps drive everything below: usage.cost_usd has no customer-price twin, and nothing is versioned.
Target model
┌── model_prices (provider book, versioned) ──┐
usage event ──> pricing engine ──┬── charge_usd (customer) ──> ledger_entries (usage) ──> invoice / wallet
tokens in/out, cached └── cost_usd (provider) ──> provider_spend ──> provider_invoices -> settlement
provider, model, price_version ▲
└── price_rules (markup, tiers, per-customer) ┘Every table is additive and portable (db.SCHEMA + Database._migrate ALTER TABLE ... ADD COLUMN; SQLite and
PostgreSQL through the ? placeholders to_postgres() rewrites). Money is stored in integer minor units
(amount_cents INTEGER, currency TEXT) - never floats on the ledger; usage.cost_usd stays a float estimate.
Phase 0 - metering fidelity (prerequisite for everything)
Extend the usage row, keeping the old columns:
| Column | Source | Why |
|---|---|---|
prompt_tokens, completion_tokens, cached_tokens | ExecutionResult.response usage (_cost_tokens reads total_tokens today; /v1 handlers already expose prompt_tokens / completion_tokens in trace attributes) | input and output are priced differently |
provider_id, upstream_model | PlatformAPI._executed_on(result) + the provider/BYOK mapping already put in X-OSR-Provider | provider cost and settlement are per provider |
charge_usd | pricing engine (Phase 1) | the customer side of the row |
price_version | pricing engine | reproducible margin |
billed_at, ledger_id | Phase 2/3 | which invoice / ledger entry consumed the row |
Set them in metered() where cost_usd is set; the fn() callables return (payload, target_id, tokens, cost) -
widen that tuple to a small dataclass (MeteredResult) rather than growing the tuple. Feature-flag nothing:
unknown values stay NULL. Keep /savings and /analytics reading cost_usd until Phase 5 switches them.
A usage event is the contract; write it once in db.record_usage and never derive billing from the UI or from
telemetry_events.
Phase 1 - price books and the pricing engine
Tables:
model_prices id, provider_id (NULL = list price), model, unit (token_in|token_out|token_cached|call|
image|second), usd_per_million REAL, currency, effective_from, effective_to, source
(catalogue|manual|contract), created_by, created_at
price_rules id, scope (deployment|plan|account), scope_id, kind (markup_pct|markup_fixed|per_million|
tier|min_charge|promo), model (NULL = all), provider_id, params JSON, effective_from,
effective_to, priority, created_by, created_at
price_versions id, created_at, created_by, note, snapshot JSON (hash of the active books + rules)Seed model_prices from models.LLM (input_usd_per_1m -> token_in, output_usd_per_1m -> token_out,
cache_read_usd_per_1m -> token_cached) in the rankings refresh so list prices track the catalogue; manual
and contract rows override by provider_id + effective_from.
The engine is a pure function in a new module pricing.py:
@dataclass(frozen=True)
class Quote:
cost_cents: int # provider side
charge_cents: int # customer side
currency: str
version: str # price_versions.id the books/rules were frozen under
lines: tuple[QuoteLine, ...] # unit, quantity, provider rate, customer rate, rule ids applied
def quote(event: UsageEvent, books: PriceBooks, rules: RuleSet, *, at: float) -> Quote: ...Rules resolve most-specific first (account > plan > deployment), then by priority; a min_charge rule
applies last. Percentage and fixed markups are the common case ($2.00 / 1M in -> 20 % -> $2.40 / 1M);
tiers take params = {"tiers": [{"upto_tokens": 1e9, "markup_pct": 20}, ...]} evaluated against the month's
volume from usage_daily. Currency conversion is a rule of kind fx with a pinned rate, applied before rounding.
Round once, at the end, half-up to cents; store both sides.
Cache PriceBooks / RuleSet in the Billing-style service object and invalidate on write (the
RegistryStore.facets() TTL + invalidate() pattern). Every write to model_prices / price_rules creates a
price_versions row and an audit entry with before and after (see RBAC below).
Router integration: keep routing on provider cost (RouteTarget.cost), because that is what the
cost objective optimises; expose the customer side to the caller as X-OSR-Charge-USD next to the existing
X-OSR-Provider header (add it to EXPOSED_HEADERS in app.py and FORWARD_RESPONSE_HEADERS in
platform/web/src/lib/api/proxy.ts). When a provider's balance (Phase 4) is exhausted, feed that into the
health breakers rather than the price - a provider we cannot pay is unhealthy, not expensive.
Phase 2 - credits wallet and the ledger
ledger_entries id, account_id, ts, kind (purchase|usage|promo|adjustment|refund|invoice_settlement),
amount_cents (signed), currency, reference (stripe payment intent / invoice / usage day /
coupon), actor_type, actor_id, note, price_version, idempotency_key UNIQUERules that keep the ledger trustworthy:
- Append-only. No
UPDATEonledger_entries; corrections are newadjustmentrows with anoteand the operator's id. The balance isSUM(amount_cents); cache it per account inaccounts.balance_centsonly as a denormalised read model rebuilt from the ledger, never written by hand. - Idempotent writes.
idempotency_key= Stripe event id for purchases (thebilling_eventstable already de-duplicates webhook ids),usage:<account>:<day>for the daily usage debit (one debit per workspace per UTC day, summed fromusage.charge_usd; the compare-and-set style ofdb.claim_overageis the model). - Purchases reuse
Billing.listing_checkout_url:mode=payment,metadata[credits_cents], success URL back to/platform/dashboard/billing?credited=<amount>;Billing.handle_eventroutescheckout.session.completedwith that metadata to acredit()method the waymetadata[listing]reacheson_purchase. - Promotional / enterprise credits are
promo/adjustmententries created from the console with a reason; coupons (coupons.py) stay the tool for plan trials, credits are money. - Low balance is an alert rule next to
alerts.quota_alert(walletrule: 20 %, exhausted), delivered through the existing notification channels and the owner-fallback e-mail;PlatformAPI.meteredrefuses spending calls (meta["spends"]) with402when the balance is exhausted and the plan is wallet-billed.
Per plan, add billing_mode (subscription = today's flat plan + overage, wallet = prepaid usage,
invoice = enterprise net terms). Do not mix overage meter events and wallet debits for the same request: the
mode decides which path sync_overage / the daily usage debit takes.
Phase 3 - invoices and tax
- Subscriptions keep Stripe as the system of record (
GET /api/v1/billing/invoicesalready lists them). - Usage charges for
subscriptionplans go to Stripe as a dollar meter (payload[value]= cents ofcharge_usd, a second Billing Meter next toosr_overage_requests) so they land on the same invoice;sync_overagebecomessync_usage_chargeswith the same CAS bookkeeping. - Wallet customers get a monthly statement rendered from
ledger_entries(PDF through the mail templates'_shell, stored underdata_dir / "statements"), not a Stripe invoice. - Enterprise (
invoicemode) uses Stripe Invoicing (collection_method=send_invoice,days_until_due). - Tax: enable Stripe Tax on Checkout (
automatic_tax[enabled]=true) and collect tax ids in the portal (customer_update.allowed_updatesalready includestax_idin the configurationstripe_setup.pycreates). Store the customer's tax status on the account for the wallet statement. - Refunds are Stripe refunds first, then a
refundledger entry keyed by the refund id from thecharge.refundedwebhook (add it toWEBHOOK_EVENTSinstripe_setup.pyandBilling.handle_event).
Phase 4 - provider finance and settlement
provider_accounts provider_id, currency, balance_cents (prepaid providers), credit_limit_cents,
contract_terms TEXT, effective_from, low_balance_cents
provider_invoices id, provider_id, period_start, period_end, amount_cents, currency, invoice_ref,
received_at, paid_at, status (expected|received|disputed|paid), noteExpected spend for a period is SUM(usage.cost_usd) grouped by provider_id (Phase 0 column); variance =
invoice - expected, shown per provider with the top models behind it. Reconciliation is a console screen,
settlement is recording paid_at - the platform does not pay providers itself. Provider balance decrements
are a projection of the same usage.cost_usd sum; when it crosses low_balance_cents raise a deployment alert
(alerts.deployment_alerts) and mark the provider healthy=False so the router fails over.
The onboarding wizard the console needs is mostly there: PRESETS (details), credentials (providers.py
encrypts), Test connection (probe() -> record_check), Discover models (upstream_models),
Model mapping (ModelMapping). Add two steps - Provider pricing (rows into model_prices with
provider_id) and Reseller pricing (a price_rules row) - and refuse enabled=True until probe() succeeded
and every mapped model has a price.
Phase 5 - revenue, cost and margin analytics
Extend admin_console.py /analytics (same days window, same daily series shape) with a finance block
and give the operator Billing page (app/platform/admin/(console)/billing/page.tsx) a Revenue tab:
revenue_cents subscriptions (MRR from live plans, the estimate `Billing.mrr_usd` makes today)
+ usage charges (SUM charge_usd) + credits consumed (ledger usage debits)
provider_cost SUM cost_usd (by provider, model, workspace)
gross_margin revenue - provider_cost - stripe fees (from invoice `balance_transaction` when expanded)
outstanding Stripe invoices open/past_due (already in the webhook log) + wallet balances
by dimension model, provider, workspace, plan, dayRender with the existing StatCard / DataTable kit; reuse the Billing page's run() helper for actions.
Customer side: the dashboard billing page gets a Usage table (day, model, provider, requests, tokens in/out, charge) from a new workspace route that reads usage with the Phase 0 columns - filters by key, tenant, model
and day; CSV through csv_response.
Phase 6 - RBAC and audit for money
Extend OPERATOR_ROLES to ("operator", "finance", "provider_manager", "superadmin") and add
PlatformAPI.require_role(op, *roles) next to require_superadmin (superadmin passes everything). Guard:
| Action | Roles |
|---|---|
| price books, price rules, refunds, adjustments, settlements | finance, superadmin |
| providers, credentials, model mapping, provider pricing | provider_manager, superadmin |
| read revenue / margin | any operator |
Every financial mutation records record_audit(action, actor_type="operator", ..., before=<old>, after=<new>, reason=<free text the console requires>) - pricing.rule_changed, pricing.model_price_changed,
ledger.adjustment, billing.refund, provider.invoice_recorded, provider.settled. The console shows
"Sathish changed GPT model X token_in from 2.00 to 2.20 USD/1M" from before / after; never overwrite or
delete audit rows (retention purges by age only).
Conventions and gates (the ones this work trips)
- New tables go into
db.SCHEMAwithCREATE TABLE IF NOT EXISTS; new columns on existing tables throughDatabase._migrate; qualify columns inON CONFLICT ... DO UPDATE SET x=table.x+1(PostgreSQL). Run the suite on PostgreSQL too (OSR_TEST_DATABASE_URL). - Routes: workspace routes in the
billingdomain (Billing.router), operator routes in theadmindomain (Billing.admin_router/ a newpricing_admin_routermounted inPlatformContext.routers("admin")), prefixes registered inservices.SERVICES; regenerateplatform/api/openapi.jsonandplatform/sdk-ts/src/generated(npm run generateinplatform/sdk-ts) or the SDK TypeScript pipeline fails. - Settings: new knobs are
PlatformSettingsfields mapped infrom_env(theOSR_PLATFORM_prefix rule); add secret-looking names toadmin_console._SECRET_MARKERS, the group to_GROUPS. Stripe's existing knobs areOSR_PLATFORM_STRIPE_SECRET,OSR_PLATFORM_STRIPE_WEBHOOK_SECRET,OSR_PLATFORM_STRIPE_PRICES,OSR_PLATFORM_STRIPE_METER_EVENT,OSR_PLATFORM_OVERAGE_SYNC_S. - Tests replace
Billing._post/_getwithFakeStripe(platform/api/tests/test_billing.py); extend that fake for payment-mode sessions, refunds and the dollar meter rather than mocking HTTP. Pricing engine tests are table-driven onquote(); ledger tests assertSUM(amount_cents)and idempotent replays. - Docs: each console page needs a row in the operator-console table of
docs/PLATFORM.md, each dashboard page a row in the dashboard table (test_platform_docs_claimschecks both); user-facing billing behaviour goes into the "Billing" section; CHANGELOG under[Unreleased]. - Prices quoted in Markdown must equal a
Plan.price_usd_month(test_documented_prices_match_the_plan_catalogue).
Pitfalls
- Never compute the customer price from the provider price at read time - pin
price_versionon the row. - Never store a mutable balance as the source of truth; rebuild
balance_centsfrom the ledger. - One request must hit exactly one billing path: overage meter (subscription plans) or wallet debit or
invoice line - decide by
Plan.billing_mode, not by what data happens to exist. - Stripe refuses mixed intervals on one subscription - the dollar usage meter must be a monthly metered price
on monthly subscriptions and a yearly one on yearly (
<plan>:overage:yearshows the pattern). - Month boundaries are UTC (
month_of()); quotas, overage, usage debits and statements must agree on it. - Provider list prices from the catalogue are list; a contract row with
provider_idmust win, and a catalogue refresh must never overwrite manual or contract rows.
osr-evaluation
Evaluate and benchmark an OpenSmartRoute router - author JSONL evaluation datasets, run `osr eval` with cost/quality frontiers, baselines, robustness, calibration (ECE, Brier, conformal) and ablation reports, load RouterBench-style presets, run off-policy evaluation of logged decisions, and gate CI on minimum accuracy.
osr-integrations
Connect OpenSmartRoute to the outside world - OpenAI-compatible LLM clients and handlers, MCP tool catalogues (stdio client, signed manifests), A2A agent cards, agent harnesses (callable, HTTP, subprocess), LangGraph nodes and conditions, Microsoft Agent Framework executors, OpenAI function-calling tool specs, persona loaders and Agent-Skills SKILL.md packages.