From a9ec0b996a52d5112960c90be06df691691e2f1d Mon Sep 17 00:00:00 2001 From: domondi1 <91575411+domondi1@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:30:45 +0000 Subject: [PATCH 1/2] Add Inferrail Hosted: shared per-run budgets with self-serve credits A hosted gateway workspace where per-run dollar budgets hold across every agent and machine using it. Provider keys are passed per request and never stored. Governed runs are metered (2,000/month free); prepaid credits can be bought by agents over x402 or by people through Stripe Checkout, from one credit ledger. Credits are granted only from settled x402 payments or signature-verified paid Checkout sessions, at most once per payment. The self-hosted gateway is unchanged. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 25 + docs/hosted/index.html | 108 +++++ docs/sitemap.xml | 3 + hosted/budget_gateway/README.md | 82 ++++ hosted/budget_gateway/requirements.txt | 8 + hosted/budget_gateway/service.py | 594 +++++++++++++++++++++++ hosted/budget_gateway/stripe_checkout.py | 98 ++++ hosted/budget_gateway/workspaces.py | 274 +++++++++++ tests/unit/hosted/test_budget_gateway.py | 410 ++++++++++++++++ 9 files changed, 1602 insertions(+) create mode 100644 docs/hosted/index.html create mode 100644 hosted/budget_gateway/README.md create mode 100644 hosted/budget_gateway/requirements.txt create mode 100644 hosted/budget_gateway/service.py create mode 100644 hosted/budget_gateway/stripe_checkout.py create mode 100644 hosted/budget_gateway/workspaces.py create mode 100644 tests/unit/hosted/test_budget_gateway.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f49b6f..1f3e917 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,6 +110,31 @@ jobs: shell: bash run: bash scripts/run_hosted_work_economics_tests.sh + hosted-budget-gateway: + # Inferrail Hosted (hosted/budget_gateway): workspaces, governed-run metering, the credit + # ledger, Stripe webhook verification and the x402 credit rail, against the real x402 SDK + # with the credential-free fake facilitator. The main job self-skips these; this one fails + # on any skip. + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - name: Install package with dev and hosted dependencies + run: pip install -e ".[dev,hosted]" + - name: Lint -- hosted/budget_gateway and its tests + run: ruff check hosted/budget_gateway tests/unit/hosted/test_budget_gateway.py + - name: Test -- Inferrail Hosted (zero skips) + shell: bash + run: | + python -m pytest tests/unit/hosted/test_budget_gateway.py -v -rs | tee budget-gateway.log + if grep -q "SKIPPED" budget-gateway.log; then + echo "::error::a skipped Inferrail Hosted test must fail this job." >&2 + exit 1 + fi + hosted-job-safe-search: runs-on: ubuntu-latest timeout-minutes: 10 diff --git a/docs/hosted/index.html b/docs/hosted/index.html new file mode 100644 index 0000000..0cfba8c --- /dev/null +++ b/docs/hosted/index.html @@ -0,0 +1,108 @@ + + + + + + Inferrail Hosted pricing + + + + + + + + + + + + +
+ Inferrail + +
+
+

For agents and the people who run them

+

Inferrail HostedOne budget per run, across your whole fleet.

+

Give each AI run a dollar ceiling in a request header. Every call in that run shares it, including parallel calls from different agents and machines, and a call that would push the run past it is refused before it reaches OpenAI or Anthropic. You bring your own provider key with each request; we never store it.

+

2,000 governed runs a month free · then $0.001 per run · no subscription

+ +
+

Price

+
+
Unit

A governed run: a distinct work id (X-Inferrail-Attribute-Work-Id) with at least one call through the gateway in a calendar month (UTC). Calls inside a run are not charged separately.

+
Free

2,000 governed runs per workspace per month.

+
After that

Prepaid credits at $0.001 per run. People pay by card: $10 for 10,000 runs. Agents pay in USDC over x402: $1 for 1,000 runs, with no account.

+
Model spend

Billed by your provider on your own key. Inferrail doesn't resell model access or mark it up.

+
Self-hosted

The open-source gateway is free and does everything on one machine: pip install inferrail. Hosted is for when many agents and machines must share one budget ledger and you'd rather not run it.

+
+
+ +
+

Start

+
    +
  • POST /v1/workspaces returns a workspace key, once. No sign-up form.
  • +
  • Point an OpenAI or Anthropic SDK at /v1 with the workspace key as the API key, and send your provider key in X-Provider-Api-Key.
  • +
  • Name the run and set its ceiling: X-Inferrail-Attribute-Work-Id, X-Inferrail-Budget-Usd.
  • +
  • GET /v1/work/<id> shows what the run cost. GET /pricing and /llms.txt publish these terms for agents.
  • +
+

Past your allowance with no credits left, a call gets HTTP 402 allowance_exhausted, listing both ways to buy. Nothing reaches your provider.

+
+ +
+

What we guarantee

+
    +
  • A run's budget is reserved before each provider call, so concurrent calls can't spend the same dollar. The method and raw results are public: per-run budget benchmark.
  • +
  • Your provider key is read from each request and never written to disk, logged, or returned.
  • +
  • Receipts hold costs and identifiers, not prompts or responses.
  • +
  • Each payment adds credits at most once. A card purchase counts only after Stripe confirms it, and a USDC purchase only after it settles.
  • +
+
+
+ + + diff --git a/docs/sitemap.xml b/docs/sitemap.xml index 18d6fa4..dee9bb2 100644 --- a/docs/sitemap.xml +++ b/docs/sitemap.xml @@ -42,4 +42,7 @@ https://tryinferrail.com/labs/ + + https://tryinferrail.com/hosted/ + diff --git a/hosted/budget_gateway/README.md b/hosted/budget_gateway/README.md new file mode 100644 index 0000000..d0f1726 --- /dev/null +++ b/hosted/budget_gateway/README.md @@ -0,0 +1,82 @@ +# Inferrail Hosted + +The Inferrail gateway, run for you. Per-run dollar budgets hold across **every agent and machine** +that calls the same workspace, and calls that would overspend a run are refused before they reach +the provider. Receipts stay payload-free. You bring your own OpenAI or Anthropic key, sent with +each request and never stored. + +The open-source gateway (`pip install inferrail`) is unchanged and unrestricted. Run it yourself +for free. This service is for teams that want one shared budget ledger for a fleet without +operating it. + +## Use it + +```bash +curl -X POST https:///v1/workspaces # returns api_key once +``` + +```python +from openai import OpenAI + +client = OpenAI( + base_url="https:///v1", + api_key="irw_...", # your workspace key + default_headers={"X-Provider-Api-Key": "sk-..."}, # your OpenAI key, never stored +) +client.chat.completions.create( + model="gpt-4o-mini", + max_tokens=200, + messages=[{"role": "user", "content": "Summarize this contract."}], + extra_headers={ + "X-Inferrail-Attribute-Work-Id": "contract-review-42", # the run + "X-Inferrail-Budget-Usd": "0.50", # its ceiling + }, +) +``` + +- `POST /v1/messages` does the same for Anthropic. +- `GET /v1/work/{work_id}` shows what a run cost. +- `GET /v1/workspace` shows usage and remaining credits. + +## Price + +`GET /pricing` and `GET /llms.txt` publish the terms: + +- A **governed run** is a distinct work id with at least one call through the gateway in a UTC + month. Calls within a run are not charged separately. +- 2,000 governed runs per month are free. After that, prepaid credits cost $0.001 per run. + - **Agents:** `POST /v1/credits/x402` buys 1,000 runs for $1 in USDC (x402 `exact`), with no + account needed. + - **People:** `POST /v1/credits/checkout` returns a Stripe Checkout link. $10 buys 10,000 runs. +- No subscription, no sales call. Your provider bills your model usage on your own key; + Inferrail never resells model access. +- Past the allowance with no credits, a call gets `402 allowance_exhausted`, listing both ways to + buy. Nothing reaches the provider. + +## Money and key safety + +| Guarantee | How | +|---|---| +| A payment grants credits at most once | `UNIQUE(source, ref)`: Stripe session id; x402 payer:nonce | +| x402 credits only after settlement | The handler records *pending*; the after-settle hook grants; a failed settlement grants nothing; `pending()` exposes any gap for reconciliation | +| Card credits only from verified events | Stripe signature (HMAC-SHA256, 5-minute tolerance); `payment_status=paid`; the amount must equal the published price | +| No double spend of one credit | Admission and consumption in one `BEGIN IMMEDIATE` transaction (concurrency test) | +| An invalid workspace is never charged | The x402 handler returns 401 before settlement | +| Provider keys never at rest | Read from the request header per call; never written, logged or returned (test reads every data file) | +| Workspace keys never at rest | Stored as SHA-256 hashes only | + +## Configure + +| Variable | Default | Meaning | +|---|---|---| +| `BG_DATA_DIR` | `/tmp/inferrail_budget_gateway` | Persistent storage (mount a disk) | +| `BG_FREE_RUNS_PER_MONTH` | `2000` | Free governed runs per workspace per month | +| `BG_PER_WORK_MAX_USD` | `100` | Largest per-run ceiling a caller may declare | +| `BG_WORKSPACE_CREATION_ENABLED`, `BG_MAX_WORKSPACES`, `BG_CREATIONS_PER_IP_PER_HOUR` | `1`, `10000`, `5` | Abuse guards | +| `BG_X402_PAY_TO` | unset (rail off) | Receiving address for agent credit purchases | +| `BG_X402_NETWORK` | `eip155:84532` | Base Sepolia. Mainnet `eip155:8453` also needs `BG_X402_MAINNET_APPROVED=1` and CDP API keys | +| `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` | unset (rail off) | Card credit purchases | +| `BG_PUBLIC_BASE_URL` | `http://localhost:8424` | Public URL used in links | + +Run: `python hosted/budget_gateway/service.py`. Tests: +`python -m pytest tests/unit/hosted/test_budget_gateway.py` (needs the `hosted` extra). diff --git a/hosted/budget_gateway/requirements.txt b/hosted/budget_gateway/requirements.txt new file mode 100644 index 0000000..e4ee5f8 --- /dev/null +++ b/hosted/budget_gateway/requirements.txt @@ -0,0 +1,8 @@ +# Runtime dependencies for Inferrail Hosted. Also install the repo itself (`pip install -e .`): +# this service imports the inferrail gateway engines, budgets, receipts and pricing. +fastapi==0.141.1 +uvicorn==0.52.4 +httpx==0.28.1 +# Only needed when the x402 credit rail is enabled (BG_X402_PAY_TO set): +x402[evm,fastapi,httpx]==2.22.0 +cdp-sdk==1.48.1 diff --git a/hosted/budget_gateway/service.py b/hosted/budget_gateway/service.py new file mode 100644 index 0000000..53f9948 --- /dev/null +++ b/hosted/budget_gateway/service.py @@ -0,0 +1,594 @@ +"""Inferrail Hosted: the Inferrail gateway run for you, with per-run budgets that hold across every +agent and machine that calls the same workspace. + +Lives outside `src/inferrail`, like the other hosted services (docs/adr/0004). The self-hosted +gateway is unchanged and unrestricted: everything it does locally stays free. What this service +adds is what one self-hosted SQLite file can't do: a single budget ledger shared by a whole +fleet, run and kept up for you. + +**How a call works** + +1. `Authorization: Bearer irw_...` identifies the workspace. The provider key travels in + `X-Provider-Api-Key` on every request and is **never stored, logged or returned**. +2. `X-Inferrail-Attribute-Work-Id` names the run, and `X-Inferrail-Budget-Usd` sets its dollar + ceiling. Concurrent calls in one run share it atomically, and a call that would exceed it gets + 402 before reaching the provider. A call without a work id is its own run. +3. The run is metered as a *governed run* (`workspaces.py`). Past the monthly free allowance with + no credits left, the call gets 402 `allowance_exhausted` with both ways to buy more: x402 USDC + for agents, Stripe Checkout for people. + +**Selling credits.** One credit ledger, two rails. +- `POST /v1/credits/x402` is paid via x402 `exact`. The handler records the purchase as pending, + keyed by the EIP-3009 nonce, and the after-settle hook turns it into credits. Nothing is granted + unless settlement succeeds. +- `POST /v1/credits/checkout` returns a Stripe Checkout URL. Only a signature-verified webhook + grants credits. + +Both rails stay off until configured. Mainnet x402 also requires `BG_X402_MAINNET_APPROVED=1`, a +recorded founder go-live approval. +""" + +from __future__ import annotations + +import os +import sys +import threading +import time +import uuid +from dataclasses import dataclass +from decimal import Decimal +from pathlib import Path +from typing import Any + +import httpx +from fastapi import Depends, FastAPI, HTTPException, Request +from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import stripe_checkout # noqa: E402 +from workspaces import WorkspaceLedger # noqa: E402 + +from inferrail.budgets.enforcement import BudgetEnforcer # noqa: E402 +from inferrail.budgets.store import BudgetStore # noqa: E402 +from inferrail.config.models import ProviderConfig # noqa: E402 +from inferrail.errors import ( # noqa: E402 + AuthenticationError, + BudgetDeclarationError, + BudgetExceededError, + InferrailError, + InvalidRequestError, + ProviderError, + ProviderTimeoutError, + RateLimitError, + RoutingError, + UnsupportedFeatureError, +) +from inferrail.errors.codes import code_for, docs_url_for # noqa: E402 +from inferrail.gateway.anthropic_execution import AnthropicInferenceEngine # noqa: E402 +from inferrail.gateway.anthropic_schemas import ( # noqa: E402 + MessagesRequest, + absent_cache_usage_fields, +) +from inferrail.gateway.attribution import extract_attributes, extract_declared_budget # noqa: E402 +from inferrail.gateway.execution import InferenceEngine # noqa: E402 +from inferrail.gateway.schemas import ( # noqa: E402 + ChatCompletionRequest, + ChatCompletionResponse, + ErrorDetail, + ErrorResponse, +) +from inferrail.pricing.resolver import PricingResolver # noqa: E402 +from inferrail.providers.anthropic import AnthropicProvider # noqa: E402 +from inferrail.providers.openai import OpenAIProvider # noqa: E402 +from inferrail.receipts.sqlite_store import ReceiptsStore # noqa: E402 +from inferrail.routing.router import Router # noqa: E402 +from inferrail.telemetry.sinks import NullTelemetrySink # noqa: E402 + +PROVIDER_KEY_HEADER = "x-provider-api-key" +MAINNET = "eip155:8453" +TESTNET = "eip155:84532" + +_STATUS_BY_ERROR: list[tuple[type[InferrailError], int]] = [ + (AuthenticationError, 401), + (BudgetExceededError, 402), + (BudgetDeclarationError, 400), + (RateLimitError, 429), + (ProviderTimeoutError, 504), + (InvalidRequestError, 400), + (UnsupportedFeatureError, 400), + (RoutingError, 400), + (ProviderError, 502), +] + + +def openai_client_factory() -> httpx.AsyncClient | None: + """Test seam (httpx.MockTransport), same as hosted/cost_gateway.""" + return None + + +def anthropic_client_factory() -> httpx.AsyncClient | None: + return None + + +def stripe_client_factory() -> httpx.AsyncClient: + return httpx.AsyncClient() + + +@dataclass(frozen=True) +class Settings: + data_dir: Path + free_runs_per_month: int = 2000 + per_work_max_usd: Decimal = Decimal("100") + creation_enabled: bool = True + max_workspaces: int = 10_000 + creations_per_ip_per_hour: int = 5 + x402_network: str = TESTNET + x402_pay_to: str | None = None + x402_pack_price_usd: str = "1.00" + x402_pack_runs: int = 1000 + stripe: stripe_checkout.StripeConfig | None = None + public_base_url: str = "http://localhost:8424" + + @classmethod + def from_env(cls, env: dict[str, str] | None = None) -> Settings: + e = dict(os.environ if env is None else env) + network = e.get("BG_X402_NETWORK", TESTNET) + if network not in (MAINNET, TESTNET): + raise ValueError(f"unsupported BG_X402_NETWORK {network}") + pay_to = e.get("BG_X402_PAY_TO") or None + if network == MAINNET and pay_to and e.get("BG_X402_MAINNET_APPROVED") != "1": + raise RuntimeError("mainnet credit sales require BG_X402_MAINNET_APPROVED=1") + stripe = None + if e.get("STRIPE_SECRET_KEY") and e.get("STRIPE_WEBHOOK_SECRET"): + base = e.get("BG_PUBLIC_BASE_URL", "http://localhost:8424") + stripe = stripe_checkout.StripeConfig( + secret_key=e["STRIPE_SECRET_KEY"], + webhook_secret=e["STRIPE_WEBHOOK_SECRET"], + pack_price_cents=int(e.get("BG_STRIPE_PACK_CENTS", "1000")), + pack_runs=int(e.get("BG_STRIPE_PACK_RUNS", "10000")), + success_url=f"{base}/v1/credits/thanks", + cancel_url=f"{base}/pricing", + ) + return cls( + data_dir=Path(e.get("BG_DATA_DIR", "/tmp/inferrail_budget_gateway")), + free_runs_per_month=int(e.get("BG_FREE_RUNS_PER_MONTH", "2000")), + per_work_max_usd=Decimal(e.get("BG_PER_WORK_MAX_USD", "100")), + creation_enabled=e.get("BG_WORKSPACE_CREATION_ENABLED", "1") == "1", + max_workspaces=int(e.get("BG_MAX_WORKSPACES", "10000")), + creations_per_ip_per_hour=int(e.get("BG_CREATIONS_PER_IP_PER_HOUR", "5")), + x402_network=network, + x402_pay_to=pay_to, + x402_pack_price_usd=e.get("BG_X402_PACK_PRICE_USD", "1.00"), + x402_pack_runs=int(e.get("BG_X402_PACK_RUNS", "1000")), + stripe=stripe, + public_base_url=e.get("BG_PUBLIC_BASE_URL", "http://localhost:8424"), + ) + + +class _Stores: + def __init__(self, data_dir: Path, pricing: PricingResolver, per_work_max: Decimal) -> None: + self._dir, self._pricing, self._max = data_dir, pricing, per_work_max + self._cache: dict[str, tuple[ReceiptsStore, BudgetEnforcer]] = {} + self._lock = threading.Lock() + + def get(self, workspace_id: str) -> tuple[ReceiptsStore, BudgetEnforcer]: + with self._lock: + hit = self._cache.get(workspace_id) + if hit is None: + receipts = ReceiptsStore(self._dir / f"{workspace_id}-receipts.sqlite3") + budgets = BudgetStore(self._dir / f"{workspace_id}-budgets.sqlite3") + enforcer = BudgetEnforcer( + budgets, + receipts, + self._pricing, + per_work_max_usd=self._max, + allow_declared_budgets=True, + ) + hit = self._cache[workspace_id] = (receipts, enforcer) + return hit + + +def _pricing() -> PricingResolver: + return PricingResolver( + providers={ + "openai": ProviderConfig(type="openai", api_key_env="BG_UNUSED_OPENAI"), + "anthropic": ProviderConfig(type="anthropic", api_key_env="BG_UNUSED_ANTHROPIC"), + }, + overrides={}, + ) + + +def _payment_ref(payload: Any) -> str | None: + auth = (getattr(payload, "payload", None) or {}).get("authorization") or {} + nonce, payer = auth.get("nonce"), auth.get("from") + return f"{str(payer).lower()}:{str(nonce).lower()}" if nonce and payer else None + + +def create_app(settings: Settings, *, facilitator: Any | None = None) -> FastAPI: + settings.data_dir.mkdir(parents=True, exist_ok=True) + ledger = WorkspaceLedger( + settings.data_dir / "workspaces.sqlite3", free_runs_per_month=settings.free_runs_per_month + ) + pricing_resolver = _pricing() # stateless; shared by every workspace + stores = _Stores(settings.data_dir, pricing_resolver, settings.per_work_max_usd) + creations: dict[str, list[float]] = {} + app = FastAPI(title="Inferrail Hosted") + + # -- pricing, published in one place -------------------------------------------------------- + def price_sheet() -> dict[str, Any]: + rails: dict[str, Any] = {} + if settings.x402_pay_to: + rails["x402"] = { + "endpoint": "POST /v1/credits/x402", + "network": settings.x402_network, + "asset": "USDC", + "price_usd": settings.x402_pack_price_usd, + "governed_runs": settings.x402_pack_runs, + "account_needed": False, + } + if settings.stripe: + rails["card"] = { + "endpoint": "POST /v1/credits/checkout", + "price_usd": f"{settings.stripe.pack_price_cents / 100:.2f}", + "governed_runs": settings.stripe.pack_runs, + } + return { + "product": "Inferrail Hosted", + "unit": ( + "governed run: a distinct work id with at least one call through the gateway " + "in a UTC month" + ), + "free_governed_runs_per_month": settings.free_runs_per_month, + "price_per_governed_run_usd": "0.001", + "credits": rails, + "subscription": None, + "self_hosted": ( + "The open-source gateway is free and unrestricted: pip install inferrail" + ), + "provider_spend": "Billed by your provider on your own key. Inferrail never resells " + "model access and never stores your provider key.", + } + + @app.get("/health") + async def health() -> dict[str, str]: + return {"status": "ok"} + + @app.get("/pricing") + async def pricing() -> dict[str, Any]: + return price_sheet() + + @app.get("/llms.txt", response_class=PlainTextResponse) + async def llms() -> str: + p = price_sheet() + return ( + "# Inferrail Hosted\n\n> Per-run dollar budgets for AI agents, enforced before the " + "provider is called and shared by every agent and machine using one workspace. " + "Payload-free receipts. Bring your own OpenAI or Anthropic key.\n\n" + f"- Create a workspace: POST {settings.public_base_url}/v1/workspaces (no account)\n" + "- OpenAI-compatible: POST /v1/chat/completions; Anthropic-compatible: POST " + "/v1/messages\n" + "- Headers: Authorization: Bearer ; X-Provider-Api-Key: ; X-Inferrail-Attribute-Work-Id: ; X-Inferrail-Budget-Usd: \n" + f"- Price: {p['free_governed_runs_per_month']} governed runs/month free, then " + f"${p['price_per_governed_run_usd']} per run via prepaid credits: GET /pricing\n" + ) + + # -- workspaces ----------------------------------------------------------------------------- + @app.post("/v1/workspaces", status_code=201) + async def create_workspace(request: Request) -> dict[str, Any]: + if not settings.creation_enabled: + raise HTTPException(503, "workspace creation is paused") + if ledger.count_workspaces() >= settings.max_workspaces: + raise HTTPException(503, "workspace capacity reached") + ip = request.client.host if request.client else "unknown" + now = time.time() + recent = [t for t in creations.get(ip, []) if now - t < 3600] + if len(recent) >= settings.creations_per_ip_per_hour: + raise HTTPException(429, "too many workspaces created from this address") + creations[ip] = recent + [now] + workspace_id, api_key = ledger.create_workspace() + return { + "workspace_id": workspace_id, + "api_key": api_key, + "api_key_notice": "Shown once. Inferrail stores only its hash.", + "base_url": settings.public_base_url + "/v1", + "pricing": price_sheet(), + } + + def _workspace(request: Request) -> str: + header = request.headers.get("authorization", "") + token = header[7:].strip() if header.lower().startswith("bearer ") else "" + workspace_id = ledger.authenticate(token) if token else None + if workspace_id is None: + raise HTTPException(401, "missing or invalid workspace key") + return workspace_id + + require_workspace = Depends(_workspace) + + @app.get("/v1/workspace") + async def workspace_status(workspace_id: str = require_workspace) -> dict[str, Any]: + u = ledger.usage(workspace_id) + return { + "workspace_id": workspace_id, + "month": u.month, + "governed_runs": u.month_runs, + "free_runs_remaining": u.free_runs_remaining, + "credits_remaining": u.credits_remaining, + "pricing": price_sheet(), + } + + # -- proxy ---------------------------------------------------------------------------------- + @app.exception_handler(InferrailError) + async def inferrail_error(_: Request, exc: InferrailError) -> JSONResponse: + status = next((s for t, s in _STATUS_BY_ERROR if isinstance(exc, t)), 500) + code = code_for(exc) + body = ErrorResponse( + error=ErrorDetail( + message=str(exc), + type=type(exc).__name__, + code=code.code, + remediation=code.remediation, + docs_url=docs_url_for(code.code), + ) + ) + return JSONResponse(status_code=status, content=body.model_dump()) + + def _admit(request: Request, workspace_id: str) -> tuple[dict[str, str], Decimal | None]: + provider_key = request.headers.get(PROVIDER_KEY_HEADER, "").strip() + if not provider_key: + raise HTTPException(400, "send your provider key in X-Provider-Api-Key") + attributes = extract_attributes(request.headers) + declared = extract_declared_budget(request.headers) + work_id = attributes.setdefault("work_id", f"call_{uuid.uuid4().hex[:16]}") + admission = ledger.admit_run(workspace_id, work_id) + if not admission.admitted: + raise HTTPException( + status_code=402, + detail={ + "error": "allowance_exhausted", + "message": "This month's free governed runs are used and no credits remain. " + "Buy credits to continue; nothing was sent to the provider.", + "purchase": price_sheet()["credits"], + }, + ) + return attributes, declared + + @app.post("/v1/chat/completions", response_model=None) + async def chat_completions( + payload: ChatCompletionRequest, request: Request, workspace_id: str = require_workspace + ) -> ChatCompletionResponse | StreamingResponse: + attributes, declared = _admit(request, workspace_id) + receipts, enforcer = stores.get(workspace_id) + provider = OpenAIProvider( + name="openai", + api_key=request.headers[PROVIDER_KEY_HEADER].strip(), + base_url="https://api.openai.com/v1", + is_verified_openai=True, + client=openai_client_factory(), + ) + engine = InferenceEngine( + Router(routes={}, default_provider="openai"), + {"openai": provider}, + NullTelemetrySink(), + pricing_resolver, + receipts, + budgets=enforcer, + ) + if payload.stream: + try: + body = await engine.prepare_stream( + payload, attributes=attributes, declared_budget_usd=declared + ) + except BaseException: + await provider.aclose() + raise + + async def stream() -> Any: + try: + async for chunk in body: + yield chunk + finally: + await provider.aclose() + + return StreamingResponse(stream(), media_type="text/event-stream") + try: + return await engine.execute( + payload, attributes=attributes, declared_budget_usd=declared + ) + finally: + await provider.aclose() + + @app.post("/v1/messages", response_model=None) + async def messages( + payload: MessagesRequest, request: Request, workspace_id: str = require_workspace + ) -> Any: + attributes, declared = _admit(request, workspace_id) + receipts, enforcer = stores.get(workspace_id) + provider = AnthropicProvider( + name="anthropic", + api_key=request.headers[PROVIDER_KEY_HEADER].strip(), + base_url="https://api.anthropic.com/v1", + client=anthropic_client_factory(), + ) + engine = AnthropicInferenceEngine( + Router(routes={}, default_provider="anthropic"), + {"anthropic": provider}, + NullTelemetrySink(), + pricing_resolver, + receipts, + budgets=enforcer, + ) + if payload.stream: + try: + body = await engine.prepare_stream( + payload, attributes=attributes, declared_budget_usd=declared + ) + except BaseException: + await provider.aclose() + raise + + async def stream() -> Any: + try: + async for chunk in body: + yield chunk + finally: + await provider.aclose() + + return StreamingResponse(stream(), media_type="text/event-stream") + try: + result = await engine.execute( + payload, attributes=attributes, declared_budget_usd=declared + ) + finally: + await provider.aclose() + return JSONResponse( + result.model_dump( + mode="json", exclude={"usage": absent_cache_usage_fields(result.usage)} + ) + ) + + @app.get("/v1/work/{work_id}") + async def work(work_id: str, workspace_id: str = require_workspace) -> dict[str, Any]: + receipts, _ = stores.get(workspace_id) + rows = receipts.query(work_id=work_id) + priced = [r.estimated_cost_usd for r in rows if r.estimated_cost_usd is not None] + return { + "work_id": work_id, + "calls": len(rows), + "cost_usd": str(sum(priced, Decimal(0))), + "unpriced_calls": len(rows) - len(priced), + "unsuccessful_calls": sum(1 for r in rows if r.status != "success"), + } + + # -- credits: card --------------------------------------------------------------------------- + @app.post("/v1/credits/checkout") + async def checkout(request: Request, workspace_id: str = require_workspace) -> dict[str, str]: + if settings.stripe is None: + raise HTTPException(501, "card purchases are not enabled on this deployment") + packs = int(request.query_params.get("packs", "1")) + if not 1 <= packs <= 100: + raise HTTPException(400, "packs must be 1-100") + async with stripe_client_factory() as client: + url = await stripe_checkout.create_session(settings.stripe, workspace_id, packs, client) + return {"checkout_url": url} + + @app.post("/v1/stripe/webhook") + async def stripe_webhook(request: Request) -> dict[str, Any]: + if settings.stripe is None: + raise HTTPException(404) + raw = await request.body() + try: + event = stripe_checkout.verify_event( + raw, request.headers.get("stripe-signature", ""), settings.stripe.webhook_secret + ) + except stripe_checkout.SignatureError as ex: + raise HTTPException(400, f"invalid signature: {ex}") from ex + paid = stripe_checkout.paid_session(event) + if paid is None: + return {"granted": False, "reason": "not a paid checkout session"} + session_id, workspace_id, packs, amount = paid + if amount != packs * settings.stripe.pack_price_cents: + return {"granted": False, "reason": "amount does not match the published price"} + granted = ledger.grant( + "stripe", session_id, workspace_id, packs * settings.stripe.pack_runs, amount + ) + return {"granted": granted} + + @app.get("/v1/credits/thanks", response_class=PlainTextResponse) + async def thanks() -> str: + return "Payment received. Credits appear once Stripe confirms it (usually seconds)." + + # -- credits: x402 --------------------------------------------------------------------------- + if settings.x402_pay_to: + from x402.http import HTTPFacilitatorClient + from x402.http.middleware.fastapi import payment_middleware + from x402.http.types import PaymentOption, RouteConfig + from x402.http.utils import decode_payment_signature_header + from x402.mechanisms.evm.exact import register_exact_evm_server + from x402.server import x402ResourceServer + + if facilitator is None: + if settings.x402_network == MAINNET: + from cdp.x402 import create_facilitator_config + + facilitator = HTTPFacilitatorClient( + create_facilitator_config( + api_key_id=os.environ["CDP_API_KEY_ID"], + api_key_secret=os.environ["CDP_API_KEY_SECRET"], + ) + ) + else: + facilitator = HTTPFacilitatorClient() + server = x402ResourceServer(facilitator) + register_exact_evm_server(server, networks=settings.x402_network) + pack_cents = int(Decimal(settings.x402_pack_price_usd) * 100) + + async def after_settle(context: Any) -> None: + ref = _payment_ref(context.payment_payload) + if ref is None: + return + if getattr(context.result, "success", False): + ledger.settle_pending("x402", ref) + else: + ledger.fail_pending("x402", ref) + + async def settle_failed(context: Any) -> None: + ref = _payment_ref(context.payment_payload) + if ref is not None: + ledger.fail_pending("x402", ref) + + server.on_after_settle(after_settle) + server.on_settle_failure(settle_failed) + routes = { + "POST /v1/credits/x402": RouteConfig( + accepts=PaymentOption( + scheme="exact", + pay_to=settings.x402_pay_to, + price=f"${settings.x402_pack_price_usd}", + network=settings.x402_network, + ), + resource=settings.public_base_url + "/v1/credits/x402", + description=( + f"{settings.x402_pack_runs:,} governed runs of Inferrail Hosted: per-run " + "dollar budgets for AI agents, enforced before the provider is called. " + "Send your workspace key as a Bearer token." + ), + mime_type="application/json", + service_name="Inferrail Hosted credits", + tags=["agent-budgets", "llm-gateway", "spend-control"], + ) + } + x402_gate = payment_middleware(routes, server) + + @app.middleware("http") + async def _x402(request: Request, call_next: Any) -> Any: + return await x402_gate(request, call_next) + + @app.post("/v1/credits/x402") + async def buy_credits_x402( + request: Request, workspace_id: str = require_workspace + ) -> dict[str, Any]: + header = request.headers.get("payment-signature") + ref = _payment_ref(decode_payment_signature_header(header)) if header else None + if ref is None: + raise HTTPException(400, "missing x402 payment") + ledger.record_pending("x402", ref, workspace_id, settings.x402_pack_runs, pack_cents) + return { + "workspace_id": workspace_id, + "governed_runs": settings.x402_pack_runs, + "status": "credits are added once the payment settles", + } + + return app + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run( + create_app(Settings.from_env()), + host="0.0.0.0", + port=int(os.environ.get("PORT", "8424")), + proxy_headers=True, + ) diff --git a/hosted/budget_gateway/stripe_checkout.py b/hosted/budget_gateway/stripe_checkout.py new file mode 100644 index 0000000..82ab8f0 --- /dev/null +++ b/hosted/budget_gateway/stripe_checkout.py @@ -0,0 +1,98 @@ +"""Card purchases of governed-run credits through Stripe Checkout. + +Disabled unless `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET` are both set. Credits are granted +only from a signature-verified `checkout.session.completed` event whose `payment_status` is +`paid`, at most once per Checkout session id. Returning to the success URL grants nothing. + +Signature verification follows Stripe's published scheme: header `Stripe-Signature: +t=,v1=`, HMAC-SHA256 of `f"{t}.{raw_body}"` with the endpoint secret, and a timestamp +tolerance against replay. No Stripe SDK dependency. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import time +from dataclasses import dataclass +from typing import Any + +import httpx + +API = "https://api.stripe.com/v1/checkout/sessions" +TOLERANCE_SECONDS = 300 + + +class SignatureError(ValueError): + pass + + +@dataclass(frozen=True) +class StripeConfig: + secret_key: str + webhook_secret: str + pack_price_cents: int + pack_runs: int + success_url: str + cancel_url: str + + +def verify_event( + raw_body: bytes, signature_header: str, secret: str, now: float | None = None +) -> dict[str, Any]: + parts: dict[str, list[str]] = {} + for item in signature_header.split(","): + key, _, value = item.strip().partition("=") + parts.setdefault(key, []).append(value) + try: + timestamp = int(parts["t"][0]) + except (KeyError, ValueError) as ex: + raise SignatureError("missing timestamp") from ex + now = time.time() if now is None else now + if abs(now - timestamp) > TOLERANCE_SECONDS: + raise SignatureError("timestamp outside tolerance") + expected = hmac.new( + secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256 + ).hexdigest() + if not any(hmac.compare_digest(expected, sig) for sig in parts.get("v1", [])): + raise SignatureError("no matching v1 signature") + return json.loads(raw_body) + + +def paid_session(event: dict[str, Any]) -> tuple[str, str, int, int] | None: + """(session_id, workspace_id, packs, amount_total_cents) for a paid Checkout session, else + None. `packs` comes from our own metadata, never from a client-supplied amount.""" + if event.get("type") != "checkout.session.completed": + return None + session = (event.get("data") or {}).get("object") or {} + if session.get("payment_status") != "paid" or session.get("mode") != "payment": + return None + meta = session.get("metadata") or {} + workspace_id, packs = meta.get("workspace_id"), meta.get("packs") + if not workspace_id or not str(packs).isdigit(): + return None + return session["id"], workspace_id, int(packs), int(session.get("amount_total") or 0) + + +async def create_session( + cfg: StripeConfig, workspace_id: str, packs: int, client: httpx.AsyncClient +) -> str: + """Create a one-time Checkout session and return its hosted payment URL.""" + form = { + "mode": "payment", + "success_url": cfg.success_url, + "cancel_url": cfg.cancel_url, + "client_reference_id": workspace_id, + "metadata[workspace_id]": workspace_id, + "metadata[packs]": str(packs), + "line_items[0][quantity]": str(packs), + "line_items[0][price_data][currency]": "usd", + "line_items[0][price_data][unit_amount]": str(cfg.pack_price_cents), + "line_items[0][price_data][product_data][name]": ( + f"Inferrail Hosted: {cfg.pack_runs:,} governed runs" + ), + } + r = await client.post(API, data=form, auth=(cfg.secret_key, ""), timeout=30) + r.raise_for_status() + return r.json()["url"] diff --git a/hosted/budget_gateway/workspaces.py b/hosted/budget_gateway/workspaces.py new file mode 100644 index 0000000..793f309 --- /dev/null +++ b/hosted/budget_gateway/workspaces.py @@ -0,0 +1,274 @@ +"""Workspaces, governed-run metering and the credit ledger for Inferrail Hosted. + +One SQLite file holds every workspace's *billing* state. Each workspace's receipts and budgets +live in their own files (see `service.py`), exactly like `hosted/cost_gateway/tenant_store.py`. + +**Value metric.** A *governed run* is a distinct work id with at least one call through the +gateway in a UTC month. The first `free_runs_per_month` runs of each month are free. Every +later new run consumes one prepaid credit. Repeat calls inside an already-counted run cost +nothing more. + +**Money rules (each has a test):** + +- Credits are granted only from a confirmed payment and at most once per payment reference + (`UNIQUE(source, ref)`): a replayed webhook or settle hook can't grant twice. +- An x402 purchase is recorded as *pending* before settlement and becomes credits only when the + settle hook confirms success. A failed settlement grants nothing. A crash in between leaves a + visible pending row for `reconcile`, never a silent loss. +- Run admission and credit consumption happen in one `BEGIN IMMEDIATE` transaction, so + concurrent first calls for new runs can't spend the same credit twice. +- API keys are stored only as SHA-256 hashes. Provider keys never reach this module. +""" + +from __future__ import annotations + +import hashlib +import secrets +import sqlite3 +import threading +import time +from dataclasses import dataclass +from pathlib import Path + +KEY_PREFIX = "irw_" + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS workspaces ( + workspace_id TEXT PRIMARY KEY, + key_hash TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + disabled INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS governed_runs ( + workspace_id TEXT NOT NULL, + month TEXT NOT NULL, + work_id TEXT NOT NULL, + first_seen INTEGER NOT NULL, + paid INTEGER NOT NULL, + PRIMARY KEY (workspace_id, month, work_id) +); +CREATE TABLE IF NOT EXISTS credit_grants ( + source TEXT NOT NULL, + ref TEXT NOT NULL, + workspace_id TEXT NOT NULL, + runs INTEGER NOT NULL CHECK (runs > 0), + amount_usd_cents INTEGER NOT NULL, + granted_at INTEGER NOT NULL, + PRIMARY KEY (source, ref) +); +CREATE TABLE IF NOT EXISTS pending_purchases ( + source TEXT NOT NULL, + ref TEXT NOT NULL, + workspace_id TEXT NOT NULL, + runs INTEGER NOT NULL, + amount_usd_cents INTEGER NOT NULL, + created_at INTEGER NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending', 'settled', 'failed')), + PRIMARY KEY (source, ref) +); +""" + + +def month_of(ts: float) -> str: + return time.strftime("%Y-%m", time.gmtime(ts)) + + +def hash_key(api_key: str) -> str: + return hashlib.sha256(api_key.encode()).hexdigest() + + +@dataclass(frozen=True) +class Admission: + admitted: bool + new_run: bool + paid: bool + month_runs: int + credits_remaining: int + + +@dataclass(frozen=True) +class Usage: + month: str + month_runs: int + free_runs_per_month: int + free_runs_remaining: int + credits_remaining: int + + +class WorkspaceLedger: + def __init__(self, path: Path, *, free_runs_per_month: int) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + self._db = sqlite3.connect( + str(path), isolation_level=None, check_same_thread=False, timeout=30 + ) + self._db.execute("PRAGMA journal_mode=WAL") + self._db.executescript(SCHEMA) + self._lock = threading.Lock() + self.free_runs_per_month = free_runs_per_month + + # -- workspaces --------------------------------------------------------------------------- + + def create_workspace(self) -> tuple[str, str]: + """Return (workspace_id, api_key). The key is shown once and stored only as a hash.""" + workspace_id = "ws_" + secrets.token_hex(10) + api_key = KEY_PREFIX + secrets.token_urlsafe(32) + with self._lock: + self._db.execute( + "INSERT INTO workspaces VALUES (?,?,?,0)", + (workspace_id, hash_key(api_key), int(time.time())), + ) + return workspace_id, api_key + + def authenticate(self, api_key: str) -> str | None: + if not api_key.startswith(KEY_PREFIX): + return None + row = self._db.execute( + "SELECT workspace_id FROM workspaces WHERE key_hash=? AND disabled=0", + (hash_key(api_key),), + ).fetchone() + return row[0] if row else None + + def count_workspaces(self) -> int: + return self._db.execute("SELECT COUNT(*) FROM workspaces").fetchone()[0] + + # -- metering ----------------------------------------------------------------------------- + + def _credits_remaining(self, workspace_id: str) -> int: + granted = self._db.execute( + "SELECT COALESCE(SUM(runs),0) FROM credit_grants WHERE workspace_id=?", (workspace_id,) + ).fetchone()[0] + consumed = self._db.execute( + "SELECT COUNT(*) FROM governed_runs WHERE workspace_id=? AND paid=1", (workspace_id,) + ).fetchone()[0] + return granted - consumed + + def admit_run(self, workspace_id: str, work_id: str, now: float | None = None) -> Admission: + """Count `work_id` as a governed run (once per month), spending a credit past the free + allowance. Refuses (admitted=False) when the allowance and credits are exhausted.""" + now = time.time() if now is None else now + month = month_of(now) + with self._lock: + self._db.execute("BEGIN IMMEDIATE") + try: + runs = self._db.execute( + "SELECT COUNT(*) FROM governed_runs WHERE workspace_id=? AND month=?", + (workspace_id, month), + ).fetchone()[0] + credits = self._credits_remaining(workspace_id) + known = self._db.execute( + "SELECT 1 FROM governed_runs WHERE workspace_id=? AND month=? AND work_id=?", + (workspace_id, month, work_id), + ).fetchone() + if known: + self._db.execute("COMMIT") + return Admission(True, False, False, runs, credits) + paid = runs >= self.free_runs_per_month + if paid and credits <= 0: + self._db.execute("COMMIT") + return Admission(False, False, False, runs, credits) + self._db.execute( + "INSERT INTO governed_runs VALUES (?,?,?,?,?)", + (workspace_id, month, work_id, int(now), int(paid)), + ) + self._db.execute("COMMIT") + return Admission(True, True, paid, runs + 1, credits - int(paid)) + except BaseException: + self._db.execute("ROLLBACK") + raise + + def usage(self, workspace_id: str, now: float | None = None) -> Usage: + month = month_of(time.time() if now is None else now) + runs = self._db.execute( + "SELECT COUNT(*) FROM governed_runs WHERE workspace_id=? AND month=?", + (workspace_id, month), + ).fetchone()[0] + return Usage( + month, + runs, + self.free_runs_per_month, + max(self.free_runs_per_month - runs, 0), + self._credits_remaining(workspace_id), + ) + + # -- credits ------------------------------------------------------------------------------ + + def grant( + self, source: str, ref: str, workspace_id: str, runs: int, amount_usd_cents: int + ) -> bool: + """Grant credits for one confirmed payment. Returns False if `(source, ref)` was already + granted (replay), True if this call granted.""" + with self._lock: + cur = self._db.execute( + "INSERT OR IGNORE INTO credit_grants VALUES (?,?,?,?,?,?)", + (source, ref, workspace_id, runs, amount_usd_cents, int(time.time())), + ) + return cur.rowcount == 1 + + def record_pending( + self, source: str, ref: str, workspace_id: str, runs: int, amount_usd_cents: int + ) -> None: + with self._lock: + self._db.execute( + "INSERT OR IGNORE INTO pending_purchases VALUES (?,?,?,?,?,?,'pending')", + (source, ref, workspace_id, runs, amount_usd_cents, int(time.time())), + ) + + def settle_pending(self, source: str, ref: str) -> bool: + """Turn a pending purchase into credits after confirmed settlement. Idempotent.""" + with self._lock: + self._db.execute("BEGIN IMMEDIATE") + try: + row = self._db.execute( + "SELECT workspace_id, runs, amount_usd_cents, state FROM pending_purchases " + "WHERE source=? AND ref=?", + (source, ref), + ).fetchone() + if row is None: + self._db.execute("COMMIT") + return False + workspace_id, runs, cents, _state = row + cur = self._db.execute( + "INSERT OR IGNORE INTO credit_grants VALUES (?,?,?,?,?,?)", + (source, ref, workspace_id, runs, cents, int(time.time())), + ) + self._db.execute( + "UPDATE pending_purchases SET state='settled' WHERE source=? AND ref=?", + (source, ref), + ) + self._db.execute("COMMIT") + return cur.rowcount == 1 + except BaseException: + self._db.execute("ROLLBACK") + raise + + def fail_pending(self, source: str, ref: str) -> None: + with self._lock: + self._db.execute( + "UPDATE pending_purchases SET state='failed' " + "WHERE source=? AND ref=? AND state='pending'", + (source, ref), + ) + + def pending(self) -> list[dict]: + return [ + dict( + zip( + ("source", "ref", "workspace_id", "runs", "amount_usd_cents", "created_at"), + r, + strict=True, + ) + ) + for r in self._db.execute( + "SELECT source, ref, workspace_id, runs, amount_usd_cents, created_at " + "FROM pending_purchases WHERE state='pending' ORDER BY created_at" + ) + ] + + def revenue(self) -> dict[str, int]: + """Granted (i.e. confirmed-paid) cents by source. Operator metric.""" + return { + s: c + for s, c in self._db.execute( + "SELECT source, SUM(amount_usd_cents) FROM credit_grants GROUP BY source" + ) + } diff --git a/tests/unit/hosted/test_budget_gateway.py b/tests/unit/hosted/test_budget_gateway.py new file mode 100644 index 0000000..428c474 --- /dev/null +++ b/tests/unit/hosted/test_budget_gateway.py @@ -0,0 +1,410 @@ +"""Tests for hosted/budget_gateway (Inferrail Hosted): workspaces, governed-run metering, the +credit ledger, Stripe webhook verification, provider-key pass-through and the x402 credit rail. + +Providers are `httpx.MockTransport` (no network, no key). The x402 rail uses the real x402 2.22.0 +middleware with an offline fake facilitator (`_x402_fake_facilitator.py`). +""" + +from __future__ import annotations + +import hashlib +import hmac +import importlib.util +import json +import sys +import threading +import time +from pathlib import Path +from types import ModuleType + +import httpx +import pytest +from fastapi.testclient import TestClient + +pytest.importorskip("x402") +pytest.importorskip("eth_account") +pytest.importorskip("fastapi") + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from _x402_fake_facilitator import ( # noqa: E402 + FakeFacilitator, + decode_payment_required, + settle_failure, + sign_payment, +) + +HOSTED = Path(__file__).resolve().parents[3] / "hosted" / "budget_gateway" +PROVIDER_KEY = "sk-test-provider-key-0123456789abcdef" +PAY_TO = "0x" + "9" * 40 + + +def _load(name: str, filename: str) -> ModuleType: + spec = importlib.util.spec_from_file_location(name, HOSTED / filename) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def mods(): + _load("workspaces", "workspaces.py") + _load("stripe_checkout", "stripe_checkout.py") + return _load("budget_gateway_service", "service.py") + + +@pytest.fixture +def ledger(tmp_path): + ws = _load("workspaces", "workspaces.py") + return ws.WorkspaceLedger(tmp_path / "w.sqlite3", free_runs_per_month=2) + + +# -- ledger --------------------------------------------------------------------------------------- + + +def test_free_allowance_then_refusal_and_repeat_runs_are_free(ledger): + ws, _ = ledger.create_workspace() + t = 1_791_000_000 + assert ledger.admit_run(ws, "a", t).admitted + assert ledger.admit_run(ws, "b", t).admitted + again = ledger.admit_run(ws, "a", t) + assert again.admitted and not again.new_run + refused = ledger.admit_run(ws, "c", t) + assert not refused.admitted and refused.month_runs == 2 + + +def test_allowance_resets_each_utc_month(ledger): + ws, _ = ledger.create_workspace() + for w in ("a", "b"): + ledger.admit_run(ws, w, 1_790_000_000) # 2026-09 + assert not ledger.admit_run(ws, "c", 1_790_000_000).admitted + assert ledger.admit_run(ws, "c", 1_792_700_000).admitted # 2026-10 + + +def test_credits_are_spent_past_the_allowance_and_then_exhaust(ledger): + ws, _ = ledger.create_workspace() + t = 1_791_000_000 + ledger.admit_run(ws, "a", t) + ledger.admit_run(ws, "b", t) + assert ledger.grant("stripe", "cs_1", ws, 1, 100) + paid = ledger.admit_run(ws, "c", t) + assert paid.admitted and paid.paid and paid.credits_remaining == 0 + assert not ledger.admit_run(ws, "d", t).admitted + + +def test_a_payment_reference_grants_at_most_once(ledger): + ws, _ = ledger.create_workspace() + assert ledger.grant("stripe", "cs_1", ws, 10, 1000) + assert not ledger.grant("stripe", "cs_1", ws, 10, 1000) + assert ledger.usage(ws).credits_remaining == 10 + assert ledger.revenue() == {"stripe": 1000} + + +def test_concurrent_new_runs_never_spend_one_credit_twice(ledger): + ws, _ = ledger.create_workspace() + t = 1_791_000_000 + ledger.admit_run(ws, "a", t) + ledger.admit_run(ws, "b", t) + ledger.grant("x402", "r1", ws, 1, 100) + results = [] + threads = [ + threading.Thread( + target=lambda i=i: results.append(ledger.admit_run(ws, f"run-{i}", t).admitted) + ) + for i in range(20) + ] + for th in threads: + th.start() + for th in threads: + th.join() + assert results.count(True) == 1 + assert ledger.usage(ws, t).credits_remaining == 0 + + +def test_pending_purchase_becomes_credits_only_on_settlement(ledger): + ws, _ = ledger.create_workspace() + ledger.record_pending("x402", "0xabc:0x1", ws, 1000, 100) + assert ledger.usage(ws).credits_remaining == 0 + assert [p["ref"] for p in ledger.pending()] == ["0xabc:0x1"] + assert ledger.settle_pending("x402", "0xabc:0x1") + assert not ledger.settle_pending("x402", "0xabc:0x1") + assert ledger.usage(ws).credits_remaining == 1000 and ledger.pending() == [] + ledger.record_pending("x402", "0xabc:0x2", ws, 1000, 100) + ledger.fail_pending("x402", "0xabc:0x2") + assert ledger.usage(ws).credits_remaining == 1000 and ledger.pending() == [] + + +def test_workspace_keys_are_stored_only_as_hashes(tmp_path, ledger): + _, key = ledger.create_workspace() + assert ledger.authenticate(key) is not None + assert ledger.authenticate(key + "x") is None + blob = b"".join(p.read_bytes() for p in tmp_path.iterdir()) + assert key.encode() not in blob + + +# -- stripe --------------------------------------------------------------------------------------- + + +def _sign(body: bytes, secret: str, ts: int | None = None) -> str: + ts = int(time.time()) if ts is None else ts + sig = hmac.new(secret.encode(), f"{ts}.".encode() + body, hashlib.sha256).hexdigest() + return f"t={ts},v1={sig}" + + +def test_stripe_signature_verification(mods): + sc = sys.modules["stripe_checkout"] + body = b'{"type":"x"}' + assert sc.verify_event(body, _sign(body, "whsec_1"), "whsec_1") == {"type": "x"} + stale = _sign(body, "whsec_1", ts=int(time.time()) - 3600) + for header in (_sign(body, "other"), stale, "v1=x"): + with pytest.raises(sc.SignatureError): + sc.verify_event(body, header, "whsec_1") + with pytest.raises(sc.SignatureError): + sc.verify_event(b'{"type":"y"}', _sign(body, "whsec_1"), "whsec_1") + + +STRIPE_ENV = {"STRIPE_SECRET_KEY": "sk_test_x", "STRIPE_WEBHOOK_SECRET": "whsec_1"} + + +def _client(mods, tmp_path, env=None, facilitator=None, **extra): + settings = mods.Settings.from_env( + { + "BG_DATA_DIR": str(tmp_path / "data"), + "BG_FREE_RUNS_PER_MONTH": "2", + **(env or {}), + **extra, + } + ) + return TestClient(mods.create_app(settings, facilitator=facilitator)) + + +def _workspace(client): + r = client.post("/v1/workspaces") + assert r.status_code == 201 + return r.json()["api_key"] + + +def _event(workspace, packs=1, amount=1000, status="paid", sid="cs_test_1"): + return json.dumps( + { + "type": "checkout.session.completed", + "data": { + "object": { + "id": sid, + "mode": "payment", + "payment_status": status, + "amount_total": amount, + "metadata": {"workspace_id": workspace, "packs": str(packs)}, + } + }, + } + ).encode() + + +def test_stripe_webhook_grants_once_and_only_for_paid_matching_sessions(mods, tmp_path): + c = _client(mods, tmp_path, STRIPE_ENV) + key = _workspace(c) + ws = c.get("/v1/workspace", headers={"Authorization": f"Bearer {key}"}).json()["workspace_id"] + + def post(body, header=None): + return c.post( + "/v1/stripe/webhook", + content=body, + headers={"Stripe-Signature": header or _sign(body, "whsec_1")}, + ) + + assert post(_event(ws, status="unpaid")).json()["granted"] is False + assert post(_event(ws, amount=1)).json()["granted"] is False + assert post(_event(ws), header=_sign(_event(ws), "wrong")).status_code == 400 + assert post(_event(ws)).json()["granted"] is True + assert post(_event(ws)).json()["granted"] is False # replay + usage = c.get("/v1/workspace", headers={"Authorization": f"Bearer {key}"}).json() + assert usage["credits_remaining"] == 10_000 + + +def test_card_rail_is_off_unless_configured(mods, tmp_path): + c = _client(mods, tmp_path) + key = _workspace(c) + r = c.post("/v1/credits/checkout", headers={"Authorization": f"Bearer {key}"}) + assert r.status_code == 501 + assert c.post("/v1/stripe/webhook", content=b"{}").status_code == 404 + assert "card" not in c.get("/pricing").json()["credits"] + + +# -- proxy ---------------------------------------------------------------------------------------- + + +def _mock_openai(calls: list): + def handler(request: httpx.Request) -> httpx.Response: + calls.append(request) + body = json.loads(request.content) + return httpx.Response( + 200, + json={ + "id": "chatcmpl-mock", + "model": body["model"], + "choices": [ + {"message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"} + ], + "usage": {"prompt_tokens": 12, "completion_tokens": 4}, + }, + ) + + return lambda: httpx.AsyncClient(transport=httpx.MockTransport(handler)) + + +def _chat(c, key, work="run-1", budget=None, provider_key=PROVIDER_KEY): + headers = {"Authorization": f"Bearer {key}", "X-Inferrail-Attribute-Work-Id": work} + if provider_key: + headers["X-Provider-Api-Key"] = provider_key + if budget: + headers["X-Inferrail-Budget-Usd"] = budget + return c.post( + "/v1/chat/completions", + headers=headers, + json={ + "model": "gpt-4o-mini", + "max_tokens": 20, + "messages": [{"role": "user", "content": "hi"}], + }, + ) + + +def test_provider_key_is_forwarded_but_never_stored_or_returned(mods, tmp_path, monkeypatch): + calls: list = [] + monkeypatch.setattr(mods, "openai_client_factory", _mock_openai(calls)) + c = _client(mods, tmp_path) + key = _workspace(c) + r = _chat(c, key) + assert r.status_code == 200, r.text + assert calls[0].headers["authorization"] == f"Bearer {PROVIDER_KEY}" + assert PROVIDER_KEY not in r.text + blob = b"".join(p.read_bytes() for p in (tmp_path / "data").iterdir() if p.is_file()) + assert PROVIDER_KEY.encode() not in blob + work = c.get("/v1/work/run-1", headers={"Authorization": f"Bearer {key}"}).json() + assert work["calls"] == 1 + + +def test_missing_provider_key_is_refused_without_metering(mods, tmp_path, monkeypatch): + calls: list = [] + monkeypatch.setattr(mods, "openai_client_factory", _mock_openai(calls)) + c = _client(mods, tmp_path) + key = _workspace(c) + assert _chat(c, key, provider_key=None).status_code == 400 + assert ( + c.get("/v1/workspace", headers={"Authorization": f"Bearer {key}"}).json()["governed_runs"] + == 0 + ) + assert calls == [] + + +def test_unknown_workspace_key_is_rejected(mods, tmp_path): + c = _client(mods, tmp_path) + assert _chat(c, "irw_nope").status_code == 401 + + +def test_exhausted_allowance_gets_402_with_purchase_options_before_the_provider( + mods, tmp_path, monkeypatch +): + calls: list = [] + monkeypatch.setattr(mods, "openai_client_factory", _mock_openai(calls)) + c = _client(mods, tmp_path, {"BG_X402_PAY_TO": PAY_TO}, facilitator=FakeFacilitator()) + key = _workspace(c) + assert _chat(c, key, "a").status_code == 200 + assert _chat(c, key, "a").status_code == 200 # same run: no new charge + assert _chat(c, key, "b").status_code == 200 + r = _chat(c, key, "c") + assert r.status_code == 402 + detail = r.json()["detail"] + assert detail["error"] == "allowance_exhausted" and "x402" in detail["purchase"] + assert len(calls) == 3 + + +def test_declared_run_budget_is_enforced_before_the_provider(mods, tmp_path, monkeypatch): + calls: list = [] + monkeypatch.setattr(mods, "openai_client_factory", _mock_openai(calls)) + c = _client(mods, tmp_path) + key = _workspace(c) + r = _chat(c, key, "tiny", budget="0.000000001") + assert r.status_code == 402, r.text + assert r.json()["error"]["type"] == "BudgetExceededError" + assert calls == [] + assert _chat(c, key, "bad", budget="-1").status_code == 400 + + +# -- x402 credit rail ----------------------------------------------------------------------------- + + +def _buy(c, key, facilitator_ok=True): + unpaid = c.post("/v1/credits/x402", headers={"Authorization": f"Bearer {key}"}) + assert unpaid.status_code == 402 + signed = sign_payment(decode_payment_required(unpaid)) + return c.post( + "/v1/credits/x402", + headers={"Authorization": f"Bearer {key}", "PAYMENT-SIGNATURE": signed.header}, + ) + + +def test_x402_purchase_grants_credits_after_settlement(mods, tmp_path): + fac = FakeFacilitator() + c = _client(mods, tmp_path, {"BG_X402_PAY_TO": PAY_TO}, facilitator=fac) + key = _workspace(c) + r = _buy(c, key) + assert r.status_code == 200, r.text + assert [x.method for x in fac.calls] == ["verify", "settle"] + usage = c.get("/v1/workspace", headers={"Authorization": f"Bearer {key}"}).json() + assert usage["credits_remaining"] == 1000 + + +def test_x402_settlement_failure_grants_nothing(mods, tmp_path): + fac = FakeFacilitator(settle_outcomes=[settle_failure()]) + c = _client(mods, tmp_path, {"BG_X402_PAY_TO": PAY_TO}, facilitator=fac) + key = _workspace(c) + assert _buy(c, key).status_code == 402 + usage = c.get("/v1/workspace", headers={"Authorization": f"Bearer {key}"}).json() + assert usage["credits_remaining"] == 0 + + +def test_x402_payment_with_an_invalid_workspace_is_never_settled(mods, tmp_path): + fac = FakeFacilitator() + c = _client(mods, tmp_path, {"BG_X402_PAY_TO": PAY_TO}, facilitator=fac) + unpaid = c.post("/v1/credits/x402") + signed = sign_payment(decode_payment_required(unpaid)) + r = c.post( + "/v1/credits/x402", + headers={"Authorization": "Bearer irw_bad", "PAYMENT-SIGNATURE": signed.header}, + ) + assert r.status_code == 401 + assert fac.calls_to("settle") == [] + + +def test_mainnet_credit_sales_need_explicit_approval(mods, tmp_path): + with pytest.raises(RuntimeError): + mods.Settings.from_env({"BG_X402_NETWORK": "eip155:8453", "BG_X402_PAY_TO": PAY_TO}) + assert ( + mods.Settings.from_env( + { + "BG_X402_NETWORK": "eip155:8453", + "BG_X402_PAY_TO": PAY_TO, + "BG_X402_MAINNET_APPROVED": "1", + } + ).x402_pay_to + == PAY_TO + ) + + +def test_pricing_and_llms_txt_publish_the_same_terms(mods, tmp_path): + c = _client(mods, tmp_path, {"BG_X402_PAY_TO": PAY_TO}, facilitator=FakeFacilitator()) + p = c.get("/pricing").json() + assert p["free_governed_runs_per_month"] == 2 and p["subscription"] is None + assert p["credits"]["x402"]["price_usd"] == "1.00" + txt = c.get("/llms.txt").text + assert "X-Provider-Api-Key" in txt and "/pricing" in txt + + +def test_workspace_creation_is_throttled_per_address(mods, tmp_path): + c = _client(mods, tmp_path, BG_CREATIONS_PER_IP_PER_HOUR="2") + assert c.post("/v1/workspaces").status_code == 201 + assert c.post("/v1/workspaces").status_code == 201 + assert c.post("/v1/workspaces").status_code == 429 From d08115030f56319b732202c08c5f6bb2f7acac6a Mon Sep 17 00:00:00 2001 From: domondi1 <91575411+domondi1@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:04:20 +0000 Subject: [PATCH 2/2] Inferrail Hosted: bill only answered runs, single-instance lock, chain reconciliation, refunds - A governed run holds one unit while its calls are in flight and is billed only when the provider answers; failed or budget-refused runs cost nothing. - An exclusive lock on the data directory enforces one instance; held runs left by a crash are released at startup. - Pending x402 purchases are reconciled from USDC authorizationState, so a payment that settled before a crash is still credited exactly once. - Stripe charge.refunded reverses credits proportionally and monotonically; a negative balance blocks paid runs. - Provider keys are scrubbed from error text; ledger reads share the writer lock; mainnet x402 refuses to start without an RPC. - Tests cover concurrency, restart, isolation, payment replay and interruption, and the full customer journey. Co-Authored-By: Claude Opus 5.5 --- docs/hosted/index.html | 7 +- hosted/budget_gateway/README.md | 133 +++-- hosted/budget_gateway/chain_check.py | 50 ++ hosted/budget_gateway/service.py | 403 ++++++++++----- hosted/budget_gateway/stripe_checkout.py | 26 +- hosted/budget_gateway/workspaces.py | 473 ++++++++++++----- tests/unit/hosted/test_budget_gateway.py | 623 +++++++++++++++++++---- 7 files changed, 1316 insertions(+), 399 deletions(-) create mode 100644 hosted/budget_gateway/chain_check.py diff --git a/docs/hosted/index.html b/docs/hosted/index.html index 0cfba8c..4a04af5 100644 --- a/docs/hosted/index.html +++ b/docs/hosted/index.html @@ -69,14 +69,16 @@

For agents and the people who run them

Inferrail HostedOne budget per run, across your whole fleet.

Give each AI run a dollar ceiling in a request header. Every call in that run shares it, including parallel calls from different agents and machines, and a call that would push the run past it is refused before it reaches OpenAI or Anthropic. You bring your own provider key with each request; we never store it.

-

2,000 governed runs a month free · then $0.001 per run · no subscription

+

Experimental · 2,000 governed runs a month free · then $0.001 per run · no subscription

Price

-
Unit

A governed run: a distinct work id (X-Inferrail-Attribute-Work-Id) with at least one call through the gateway in a calendar month (UTC). Calls inside a run are not charged separately.

+
Unit

A governed run: a distinct work id (X-Inferrail-Attribute-Work-Id) in a calendar month (UTC). Calls inside a run are not charged separately.

+
Billed when

At least one call in the run is answered by your provider. A run whose calls all fail, or are all refused by its own budget, costs nothing.

Free

2,000 governed runs per workspace per month.

After that

Prepaid credits at $0.001 per run. People pay by card: $10 for 10,000 runs. Agents pay in USDC over x402: $1 for 1,000 runs, with no account.

+
No surprises

Credits are prepaid. No subscription, no auto top-up, no overage bill. When the allowance and credits run out, calls are refused before reaching your provider, with a link to buy more.

Model spend

Billed by your provider on your own key. Inferrail doesn't resell model access or mark it up.

Self-hosted

The open-source gateway is free and does everything on one machine: pip install inferrail. Hosted is for when many agents and machines must share one budget ledger and you'd rather not run it.

@@ -101,6 +103,7 @@

What we guarantee

  • Receipts hold costs and identifiers, not prompts or responses.
  • Each payment adds credits at most once. A card purchase counts only after Stripe confirms it, and a USDC purchase only after it settles.
  • +

    Experimental. These prices are a first offer and may change; credits you've already bought keep their terms.

    diff --git a/hosted/budget_gateway/README.md b/hosted/budget_gateway/README.md index d0f1726..5c6aa85 100644 --- a/hosted/budget_gateway/README.md +++ b/hosted/budget_gateway/README.md @@ -1,18 +1,28 @@ # Inferrail Hosted -The Inferrail gateway, run for you. Per-run dollar budgets hold across **every agent and machine** -that calls the same workspace, and calls that would overspend a run are refused before they reach -the provider. Receipts stay payload-free. You bring your own OpenAI or Anthropic key, sent with -each request and never stored. +**Status: experimental. Not deployed. Pricing is an experiment, not validated demand.** -The open-source gateway (`pip install inferrail`) is unchanged and unrestricted. Run it yourself -for free. This service is for teams that want one shared budget ledger for a fleet without -operating it. +The Inferrail gateway, run for you. Give each AI run a dollar ceiling in a request header. Every +call in that run, from any agent or machine using the same workspace, draws on one ledger. A +call that would push the run past its ceiling is refused before it reaches OpenAI or Anthropic. +Receipts stay payload-free. You bring your own provider key, sent with each request and never +stored. -## Use it +## What it adds to the free, self-hosted gateway + +The open-source gateway (`pip install inferrail`) already enforces per-run budgets on one +machine, and **this service doesn't change or restrict it.** + +Inferrail Hosted is for when you'd rather not run it yourself, or when many agents and machines +must share one budget. It adds: +- operation: a durable workspace; +- one central ledger for every client of the workspace; +- self-service billing. + +## Quickstart ```bash -curl -X POST https:///v1/workspaces # returns api_key once +curl -s -X POST https:///v1/workspaces # returns {"api_key": "irw_...", ...} once ``` ```python @@ -20,8 +30,8 @@ from openai import OpenAI client = OpenAI( base_url="https:///v1", - api_key="irw_...", # your workspace key - default_headers={"X-Provider-Api-Key": "sk-..."}, # your OpenAI key, never stored + api_key="irw_...", # workspace key + default_headers={"X-Provider-Api-Key": "sk-..."}, # your OpenAI key: never stored ) client.chat.completions.create( model="gpt-4o-mini", @@ -34,49 +44,86 @@ client.chat.completions.create( ) ``` -- `POST /v1/messages` does the same for Anthropic. -- `GET /v1/work/{work_id}` shows what a run cost. -- `GET /v1/workspace` shows usage and remaining credits. +- `GET /v1/work/contract-review-42` lists the run's calls, cost and receipts. +- `GET /v1/workspace` shows usage and credits. +- `GET /v1/workspace/ledger` lists every credit grant, refund and pending purchase. +- Anthropic works the same way through `POST /v1/messages`. -## Price +To try it locally with no payments: `python hosted/budget_gateway/service.py`, then use +`http://localhost:8424`. -`GET /pricing` and `GET /llms.txt` publish the terms: +## Billing (experimental pricing) -- A **governed run** is a distinct work id with at least one call through the gateway in a UTC - month. Calls within a run are not charged separately. -- 2,000 governed runs per month are free. After that, prepaid credits cost $0.001 per run. - - **Agents:** `POST /v1/credits/x402` buys 1,000 runs for $1 in USDC (x402 `exact`), with no - account needed. - - **People:** `POST /v1/credits/checkout` returns a Stripe Checkout link. $10 buys 10,000 runs. -- No subscription, no sales call. Your provider bills your model usage on your own key; - Inferrail never resells model access. -- Past the allowance with no credits, a call gets `402 allowance_exhausted`, listing both ways to - buy. Nothing reaches the provider. +| Term | Definition | +|---|---| +| **Governed run** | A distinct work id (`X-Inferrail-Attribute-Work-Id`) in a UTC month. A call without a work id is its own run | +| **When a run is billed** | Only when at least one of its calls is **answered by the provider**. Runs whose calls all fail (provider error, timeout) or are refused by their own budget **cost nothing**. Later calls in a billed run cost nothing more | +| **Free** | 2,000 governed runs per workspace per month | +| **After that** | Prepaid credits, $0.001 per run. Agents: `POST /v1/credits/x402`, $1 in USDC for 1,000 runs, no account. People: `POST /v1/credits/checkout` (Stripe), $10 for 10,000 runs | +| **No surprise charges** | No subscription, no auto top-up, no overage billing. Past the allowance with no credits, a call gets `402 allowance_exhausted`, listing how to buy, and nothing reaches the provider | +| **Model spend** | Billed by your provider on your own key. Inferrail never resells model access | +| **Refunds** | A Stripe refund reverses the matching credits automatically (proportional for partial refunds). A negative balance blocks paid runs until topped up | -## Money and key safety +While a run's first call is in flight, the run *holds* one unit (a free slot, or one credit), so +concurrent new runs can't overspend. The hold is released if no call is answered. -| Guarantee | How | -|---|---| -| A payment grants credits at most once | `UNIQUE(source, ref)`: Stripe session id; x402 payer:nonce | -| x402 credits only after settlement | The handler records *pending*; the after-settle hook grants; a failed settlement grants nothing; `pending()` exposes any gap for reconciliation | -| Card credits only from verified events | Stripe signature (HMAC-SHA256, 5-minute tolerance); `payment_status=paid`; the amount must equal the published price | -| No double spend of one credit | Admission and consumption in one `BEGIN IMMEDIATE` transaction (concurrency test) | -| An invalid workspace is never charged | The x402 handler returns 401 before settlement | -| Provider keys never at rest | Read from the request header per call; never written, logged or returned (test reads every data file) | -| Workspace keys never at rest | Stored as SHA-256 hashes only | +## Guarantees and how they're tested -## Configure +| Guarantee | Mechanism | Test | +|---|---|---| +| Concurrent calls in one run can't overspend its budget | The self-hosted gateway's atomic reservation (`BudgetEnforcer`), per workspace | `test_concurrent_calls_in_one_run_cannot_overspend_its_budget` (12 parallel calls) | +| A run's spend survives restarts | Reservations and receipts are SQLite, `synchronous=FULL` on the billing ledger | `test_budget_state_survives_a_restart` | +| Workspaces are isolated | Separate receipt/budget files per workspace; keys resolve to one workspace | `test_workspaces_are_isolated` | +| One credit can't be spent twice | Admission and credit consumption in one `BEGIN IMMEDIATE` transaction | `test_concurrent_new_runs_never_spend_one_credit_twice` (25 threads) | +| A crash mid-call doesn't leak a held credit | Held runs are released at startup; billed runs are kept | `test_restart_releases_orphaned_holds_but_keeps_billed_runs` | +| A payment grants credits at most once | `UNIQUE(source, ref)`: Stripe session id; x402 payer:nonce | `test_a_payment_reference_grants_at_most_once`, webhook replay tests | +| A checkout redirect grants nothing | Only a signature-verified `checkout.session.completed` with `payment_status=paid` and the published amount grants | `test_checkout_redirect_alone_grants_nothing`, `…only_for_paid_matching_sessions` | +| An interrupted webhook is credited exactly once | The grant is one transaction; Stripe retries non-2xx | `test_interrupted_webhook_processing_grants_exactly_once_on_retry` | +| A settled x402 payment is never lost | Recorded *pending* before settlement; credited by the settle hook, **or** by on-chain reconciliation (USDC `authorizationState`) if the process dies in between | `test_a_settled_x402_payment_is_not_lost_when_the_grant_crashes`, `test_reconciliation_resolves_pending_purchases_from_chain_truth` | +| Failed x402 settlement grants nothing | Pending stays until the chain decides; expired and unused → failed | `test_x402_settlement_failure_grants_nothing` | +| An invalid workspace is never charged | The x402 handler returns 401, so the middleware never settles | `test_x402_payment_with_an_invalid_workspace_is_never_settled` | +| No spending a nonexistent balance | Paid runs need a balance > 0; refunds can make it negative, which blocks | `test_refunds_reverse_credits_monotonically_and_block_negative_balances` | +| Provider keys never at rest or echoed | Read per request; scrubbed from error text; unhandled errors log the type only | `test_provider_key_is_forwarded_but_never_stored_or_returned`, `test_provider_errors_never_echo_the_key_and_are_not_billed` | +| Workspace keys never at rest | SHA-256 hashes only | `test_workspace_keys_are_stored_only_as_hashes` | + +## Deployment constraints + +- **Exactly one instance per data directory**, with one uvicorn worker. + - Many client machines may call it; that's the "shared across your fleet" guarantee. + - **Multiple gateway replicas sharing state are not supported and not claimed.** + - `create_app` takes an exclusive `flock` on `BG_DATA_DIR/instance.lock` and refuses to start + if another instance holds it. +- `BG_DATA_DIR` must be a **local persistent disk**. `flock` and SQLite WAL are unreliable on + network filesystems. +- Run it behind TLS. Set `BG_FORWARDED_ALLOW_IPS` to your proxy so the per-IP + workspace-creation throttle sees real client addresses. +- **Mainnet x402 requires:** `BG_X402_MAINNET_APPROVED=1`, `BG_BASE_RPC_URL` (for + reconciliation; startup refuses without it), and `CDP_API_KEY_ID` / `CDP_API_KEY_SECRET` for + the facilitator. | Variable | Default | Meaning | |---|---|---| -| `BG_DATA_DIR` | `/tmp/inferrail_budget_gateway` | Persistent storage (mount a disk) | +| `BG_DATA_DIR` | `/tmp/inferrail_budget_gateway` | Persistent storage | | `BG_FREE_RUNS_PER_MONTH` | `2000` | Free governed runs per workspace per month | | `BG_PER_WORK_MAX_USD` | `100` | Largest per-run ceiling a caller may declare | | `BG_WORKSPACE_CREATION_ENABLED`, `BG_MAX_WORKSPACES`, `BG_CREATIONS_PER_IP_PER_HOUR` | `1`, `10000`, `5` | Abuse guards | -| `BG_X402_PAY_TO` | unset (rail off) | Receiving address for agent credit purchases | -| `BG_X402_NETWORK` | `eip155:84532` | Base Sepolia. Mainnet `eip155:8453` also needs `BG_X402_MAINNET_APPROVED=1` and CDP API keys | -| `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` | unset (rail off) | Card credit purchases | +| `BG_MAX_BODY_BYTES` | `2000000` | Request size limit (by `Content-Length`) | +| `BG_X402_PAY_TO`, `BG_X402_NETWORK` | unset (rail off), `eip155:84532` | Agent credit purchases | +| `BG_BASE_RPC_URL`, `BG_RECONCILE_SECONDS` | unset, `60` | Chain reconciliation of pending x402 purchases | +| `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` | unset (rail off) | Card purchases; webhook events `checkout.session.completed`, `charge.refunded` | | `BG_PUBLIC_BASE_URL` | `http://localhost:8424` | Public URL used in links | +| `BG_FORWARDED_ALLOW_IPS` | `127.0.0.1` | Trusted proxy addresses for client IPs | + +## Known limits (before real money) + +- **Request limits:** no per-workspace request rate limit (provider spend is on the customer's + own key; CPU abuse is bounded only by the proxy). Chunked request bodies without + `Content-Length` aren't size-checked. +- **x402 refunds** are manual: the operator sends USDC back, then records the reversal through + `WorkspaceLedger.apply_refund("x402", ref, cents)`. There's no HTTP route for it. +- **Stripe disputes** (chargebacks) are not handled automatically. Treat a dispute as a refund. +- **No migrations:** the ledger schema is v1. A schema change needs a migration before deploy. +- **Backups:** copy `BG_DATA_DIR` with SQLite's online backup. Restoring an old copy can + re-open spent credits, so reconcile against Stripe and the chain after any restore. -Run: `python hosted/budget_gateway/service.py`. Tests: -`python -m pytest tests/unit/hosted/test_budget_gateway.py` (needs the `hosted` extra). +Tests: `python -m pytest tests/unit/hosted/test_budget_gateway.py` (needs the `hosted` extra). diff --git a/hosted/budget_gateway/chain_check.py b/hosted/budget_gateway/chain_check.py new file mode 100644 index 0000000..eccd9ae --- /dev/null +++ b/hosted/budget_gateway/chain_check.py @@ -0,0 +1,50 @@ +"""On-chain truth for pending x402 credit purchases. + +USDC implements EIP-3009 `authorizationState(authorizer, nonce) -> bool`: true once a transfer +authorization has been used, i.e. the payment settled. This is how a purchase that settled +while the service crashed (before its after-settle hook ran) is still credited. It is checked +against the chain itself, not against our own records. +""" + +from __future__ import annotations + +import httpx + +SELECTOR = "0xe94a0102" # keccak256("authorizationState(address,bytes32)")[:4] +USDC = { + "eip155:8453": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "eip155:84532": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", +} + + +def calldata(authorizer: str, nonce: str) -> str: + addr = authorizer.lower().removeprefix("0x").rjust(64, "0") + n = nonce.lower().removeprefix("0x").rjust(64, "0") + if len(addr) != 64 or len(n) != 64: + raise ValueError("authorizer must be an address and nonce 32 bytes") + return SELECTOR + addr + n + + +def authorization_used( + rpc_url: str, network: str, authorizer: str, nonce: str, client: httpx.Client | None = None +) -> bool | None: + """True/False from the chain, or None when the answer is unknown (RPC failure).""" + body = { + "jsonrpc": "2.0", + "id": 1, + "method": "eth_call", + "params": [{"to": USDC[network], "data": calldata(authorizer, nonce)}, "latest"], + } + try: + own = client is None + c = client or httpx.Client(timeout=20) + try: + result = c.post(rpc_url, json=body).json().get("result") + finally: + if own: + c.close() + except (httpx.HTTPError, ValueError): + return None + if not isinstance(result, str) or not result.startswith("0x") or len(result) != 66: + return None + return int(result, 16) != 0 diff --git a/hosted/budget_gateway/service.py b/hosted/budget_gateway/service.py index 53f9948..078da2c 100644 --- a/hosted/budget_gateway/service.py +++ b/hosted/budget_gateway/service.py @@ -1,40 +1,56 @@ -"""Inferrail Hosted: the Inferrail gateway run for you, with per-run budgets that hold across every -agent and machine that calls the same workspace. +"""Inferrail Hosted: the Inferrail gateway run for you. Per-run budgets hold across every agent and +machine that calls the same workspace. Lives outside `src/inferrail`, like the other hosted services (docs/adr/0004). The self-hosted -gateway is unchanged and unrestricted: everything it does locally stays free. What this service -adds is what one self-hosted SQLite file can't do: a single budget ledger shared by a whole -fleet, run and kept up for you. +gateway is unchanged and unrestricted. What this service adds is operation: a durable workspace, +one central budget ledger that every client of the workspace shares, and self-service billing. -**How a call works** +**Deployment constraint: exactly one instance per data directory.** +- Every guarantee comes from one process owning its SQLite files. Many client machines may call + one instance. +- Multiple gateway replicas sharing state are **not supported**. +- `create_app` takes an exclusive OS lock on the data directory and refuses to start if + another instance holds it. -1. `Authorization: Bearer irw_...` identifies the workspace. The provider key travels in - `X-Provider-Api-Key` on every request and is **never stored, logged or returned**. -2. `X-Inferrail-Attribute-Work-Id` names the run, and `X-Inferrail-Budget-Usd` sets its dollar - ceiling. Concurrent calls in one run share it atomically, and a call that would exceed it gets - 402 before reaching the provider. A call without a work id is its own run. -3. The run is metered as a *governed run* (`workspaces.py`). Past the monthly free allowance with - no credits left, the call gets 402 `allowance_exhausted` with both ways to buy more: x402 USDC - for agents, Stripe Checkout for people. - -**Selling credits.** One credit ledger, two rails. -- `POST /v1/credits/x402` is paid via x402 `exact`. The handler records the purchase as pending, - keyed by the EIP-3009 nonce, and the after-settle hook turns it into credits. Nothing is granted - unless settlement succeeds. -- `POST /v1/credits/checkout` returns a Stripe Checkout URL. Only a signature-verified webhook - grants credits. - -Both rails stay off until configured. Mainnet x402 also requires `BG_X402_MAINNET_APPROVED=1`, a -recorded founder go-live approval. +**How a call works** +1. `Authorization: Bearer irw_...` identifies the workspace. +2. The provider key travels in `X-Provider-Api-Key` on every request. It is never stored, + logged or returned, and it is scrubbed from any error text. +3. `X-Inferrail-Attribute-Work-Id` names the run, and `X-Inferrail-Budget-Usd` sets its ceiling. + Concurrent calls in one run share it through the same atomic reservation as the self-hosted + gateway. +4. The run is metered (`workspaces.py`): billed only if one of its calls is answered by the + provider. +5. Past the free allowance with no credits, a call gets 402 `allowance_exhausted`. Nothing is + sent to the provider. + +**Credits, one ledger, two rails** +- `POST /v1/credits/x402`, agents, USDC: + - the handler records a pending purchase, keyed by payer and EIP-3009 nonce, before + settlement; + - the after-settle hook grants credits; + - a crash in between is resolved from the chain (`chain_check.py`) at startup and + periodically. +- `POST /v1/credits/checkout`, people, card: + - Stripe Checkout; + - only a signature-verified webhook grants credits; + - refunds reverse them. + +Both rails are off until configured. Mainnet x402 also requires `BG_X402_MAINNET_APPROVED=1`. """ from __future__ import annotations +import asyncio +import contextlib +import fcntl +import logging import os import sys import threading import time import uuid +from collections.abc import AsyncIterator from dataclasses import dataclass from decimal import Decimal from pathlib import Path @@ -46,6 +62,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) +import chain_check # noqa: E402 import stripe_checkout # noqa: E402 from workspaces import WorkspaceLedger # noqa: E402 @@ -74,7 +91,6 @@ from inferrail.gateway.execution import InferenceEngine # noqa: E402 from inferrail.gateway.schemas import ( # noqa: E402 ChatCompletionRequest, - ChatCompletionResponse, ErrorDetail, ErrorResponse, ) @@ -85,9 +101,16 @@ from inferrail.routing.router import Router # noqa: E402 from inferrail.telemetry.sinks import NullTelemetrySink # noqa: E402 +log = logging.getLogger("inferrail.hosted") PROVIDER_KEY_HEADER = "x-provider-api-key" MAINNET = "eip155:8453" TESTNET = "eip155:84532" +BILLING_RULE = ( + "A governed run is a distinct work id (X-Inferrail-Attribute-Work-Id) in a UTC month. It is " + "billed only if at least one of its calls is answered by the provider; runs whose calls all " + "fail or are refused by their own budget cost nothing. Calls without a work id are each " + "their own run." +) _STATUS_BY_ERROR: list[tuple[type[InferrailError], int]] = [ (AuthenticationError, 401), @@ -115,6 +138,15 @@ def stripe_client_factory() -> httpx.AsyncClient: return httpx.AsyncClient() +def authorization_checker(settings: Settings) -> Any: + """`(authorizer, nonce) -> bool | None` against the chain, or None if no RPC is configured.""" + if not settings.rpc_url: + return None + return lambda authorizer, nonce: chain_check.authorization_used( + settings.rpc_url or "", settings.x402_network, authorizer, nonce + ) + + @dataclass(frozen=True) class Settings: data_dir: Path @@ -123,10 +155,13 @@ class Settings: creation_enabled: bool = True max_workspaces: int = 10_000 creations_per_ip_per_hour: int = 5 + max_body_bytes: int = 2_000_000 x402_network: str = TESTNET x402_pay_to: str | None = None x402_pack_price_usd: str = "1.00" x402_pack_runs: int = 1000 + rpc_url: str | None = None + reconcile_seconds: int = 60 stripe: stripe_checkout.StripeConfig | None = None public_base_url: str = "http://localhost:8424" @@ -139,9 +174,13 @@ def from_env(cls, env: dict[str, str] | None = None) -> Settings: pay_to = e.get("BG_X402_PAY_TO") or None if network == MAINNET and pay_to and e.get("BG_X402_MAINNET_APPROVED") != "1": raise RuntimeError("mainnet credit sales require BG_X402_MAINNET_APPROVED=1") + if network == MAINNET and pay_to and not e.get("BG_BASE_RPC_URL"): + # Without chain reconciliation, a payment that settled while the process crashed + # could stay uncredited. Refuse rather than risk it. + raise RuntimeError("mainnet credit sales require BG_BASE_RPC_URL for reconciliation") + base = e.get("BG_PUBLIC_BASE_URL", "http://localhost:8424") stripe = None if e.get("STRIPE_SECRET_KEY") and e.get("STRIPE_WEBHOOK_SECRET"): - base = e.get("BG_PUBLIC_BASE_URL", "http://localhost:8424") stripe = stripe_checkout.StripeConfig( secret_key=e["STRIPE_SECRET_KEY"], webhook_secret=e["STRIPE_WEBHOOK_SECRET"], @@ -157,15 +196,35 @@ def from_env(cls, env: dict[str, str] | None = None) -> Settings: creation_enabled=e.get("BG_WORKSPACE_CREATION_ENABLED", "1") == "1", max_workspaces=int(e.get("BG_MAX_WORKSPACES", "10000")), creations_per_ip_per_hour=int(e.get("BG_CREATIONS_PER_IP_PER_HOUR", "5")), + max_body_bytes=int(e.get("BG_MAX_BODY_BYTES", "2000000")), x402_network=network, x402_pay_to=pay_to, x402_pack_price_usd=e.get("BG_X402_PACK_PRICE_USD", "1.00"), x402_pack_runs=int(e.get("BG_X402_PACK_RUNS", "1000")), + rpc_url=e.get("BG_BASE_RPC_URL") or None, + reconcile_seconds=int(e.get("BG_RECONCILE_SECONDS", "60")), stripe=stripe, - public_base_url=e.get("BG_PUBLIC_BASE_URL", "http://localhost:8424"), + public_base_url=base, ) +class InstanceLockError(RuntimeError): + pass + + +def _acquire_instance_lock(data_dir: Path) -> int: + fd = os.open(data_dir / "instance.lock", os.O_RDWR | os.O_CREAT, 0o600) + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError as ex: + os.close(fd) + raise InstanceLockError( + f"another Inferrail Hosted instance is using {data_dir}; exactly one instance per " + "data directory is supported" + ) from ex + return fd + + class _Stores: def __init__(self, data_dir: Path, pricing: PricingResolver, per_work_max: Decimal) -> None: self._dir, self._pricing, self._max = data_dir, pricing, per_work_max @@ -199,23 +258,77 @@ def _pricing() -> PricingResolver: ) -def _payment_ref(payload: Any) -> str | None: +def _authorization(payload: Any) -> dict[str, Any]: auth = (getattr(payload, "payload", None) or {}).get("authorization") or {} + return auth if isinstance(auth, dict) else {} + + +def _payment_ref(payload: Any) -> str | None: + auth = _authorization(payload) nonce, payer = auth.get("nonce"), auth.get("from") return f"{str(payer).lower()}:{str(nonce).lower()}" if nonce and payer else None +def _scrub(text: str, request: Request) -> str: + key = request.headers.get(PROVIDER_KEY_HEADER, "").strip() + return text.replace(key, "[redacted]") if len(key) >= 8 else text + + def create_app(settings: Settings, *, facilitator: Any | None = None) -> FastAPI: settings.data_dir.mkdir(parents=True, exist_ok=True) + lock_fd = _acquire_instance_lock(settings.data_dir) ledger = WorkspaceLedger( settings.data_dir / "workspaces.sqlite3", free_runs_per_month=settings.free_runs_per_month ) + released = ledger.release_orphaned_holds() + if released: + log.warning("released %d run holds left by a previous process", released) pricing_resolver = _pricing() # stateless; shared by every workspace stores = _Stores(settings.data_dir, pricing_resolver, settings.per_work_max_usd) creations: dict[str, list[float]] = {} - app = FastAPI(title="Inferrail Hosted") - - # -- pricing, published in one place -------------------------------------------------------- + checker = authorization_checker(settings) if settings.x402_pay_to else None + + def reconcile() -> dict[str, int] | None: + return ledger.reconcile_pending(checker) if checker else None + + @contextlib.asynccontextmanager + async def lifespan(_: FastAPI) -> AsyncIterator[None]: + task = None + if checker: + await asyncio.to_thread(reconcile) + + async def loop() -> None: + while True: + await asyncio.sleep(settings.reconcile_seconds) + try: + await asyncio.to_thread(reconcile) + except Exception: # keep reconciling; never take the API down + log.exception("reconciliation pass failed") + + task = asyncio.create_task(loop()) + yield + if task: + task.cancel() + + app = FastAPI(title="Inferrail Hosted", lifespan=lifespan) + app.state.ledger = ledger + app.state.reconcile = reconcile + + def release_instance_lock() -> None: + with contextlib.suppress(OSError): + fcntl.flock(lock_fd, fcntl.LOCK_UN) + os.close(lock_fd) + + app.state.release_instance_lock = release_instance_lock + + @app.middleware("http") + async def body_limit(request: Request, call_next: Any) -> Any: + length = request.headers.get("content-length") + if length and length.isdigit() and int(length) > settings.max_body_bytes: + return JSONResponse({"detail": "request body too large"}, status_code=413) + return await call_next(request) + + # -- published terms --------------------------------------------------------------------------- def price_sheet() -> dict[str, Any]: rails: dict[str, Any] = {} if settings.x402_pay_to: @@ -235,19 +348,25 @@ def price_sheet() -> dict[str, Any]: } return { "product": "Inferrail Hosted", - "unit": ( - "governed run: a distinct work id with at least one call through the gateway " - "in a UTC month" - ), + "status": "experimental pricing", + "billable_unit": BILLING_RULE, "free_governed_runs_per_month": settings.free_runs_per_month, "price_per_governed_run_usd": "0.001", "credits": rails, + "no_surprise_charges": ( + "Credits are prepaid; there is no subscription, no auto top-up and no overage " + "billing. When the allowance and credits run out, calls are refused with 402 " + "before reaching the provider." + ), "subscription": None, "self_hosted": ( "The open-source gateway is free and unrestricted: pip install inferrail" ), - "provider_spend": "Billed by your provider on your own key. Inferrail never resells " - "model access and never stores your provider key.", + "provider_spend": ( + "Billed by your provider on your own key. Inferrail never resells model access " + "and never stores your provider key." + ), + "deployment": "single instance; budgets are shared by every client of a workspace", } @app.get("/health") @@ -262,19 +381,20 @@ async def pricing() -> dict[str, Any]: async def llms() -> str: p = price_sheet() return ( - "# Inferrail Hosted\n\n> Per-run dollar budgets for AI agents, enforced before the " - "provider is called and shared by every agent and machine using one workspace. " + "# Inferrail Hosted\n\n> Per-run dollar budgets for AI agents, reserved before every " + "provider call and shared by every agent and machine using one workspace. " "Payload-free receipts. Bring your own OpenAI or Anthropic key.\n\n" f"- Create a workspace: POST {settings.public_base_url}/v1/workspaces (no account)\n" "- OpenAI-compatible: POST /v1/chat/completions; Anthropic-compatible: POST " "/v1/messages\n" "- Headers: Authorization: Bearer ; X-Provider-Api-Key: ; X-Inferrail-Attribute-Work-Id: ; X-Inferrail-Budget-Usd: \n" + f"- Billing: {BILLING_RULE}\n" f"- Price: {p['free_governed_runs_per_month']} governed runs/month free, then " f"${p['price_per_governed_run_usd']} per run via prepaid credits: GET /pricing\n" ) - # -- workspaces ----------------------------------------------------------------------------- + # -- workspaces ------------------------------------------------------------------------------- @app.post("/v1/workspaces", status_code=201) async def create_workspace(request: Request) -> dict[str, Any]: if not settings.creation_enabled: @@ -286,13 +406,15 @@ async def create_workspace(request: Request) -> dict[str, Any]: recent = [t for t in creations.get(ip, []) if now - t < 3600] if len(recent) >= settings.creations_per_ip_per_hour: raise HTTPException(429, "too many workspaces created from this address") - creations[ip] = recent + [now] + creations[ip] = [*recent, now] workspace_id, api_key = ledger.create_workspace() return { "workspace_id": workspace_id, "api_key": api_key, "api_key_notice": "Shown once. Inferrail stores only its hash.", "base_url": settings.public_base_url + "/v1", + "next": "Send a call with Authorization: Bearer , X-Provider-Api-Key, " + "X-Inferrail-Attribute-Work-Id and X-Inferrail-Budget-Usd.", "pricing": price_sheet(), } @@ -313,19 +435,24 @@ async def workspace_status(workspace_id: str = require_workspace) -> dict[str, A "workspace_id": workspace_id, "month": u.month, "governed_runs": u.month_runs, + "billed_runs": u.billed_runs, "free_runs_remaining": u.free_runs_remaining, "credits_remaining": u.credits_remaining, "pricing": price_sheet(), } - # -- proxy ---------------------------------------------------------------------------------- + @app.get("/v1/workspace/ledger") + async def workspace_ledger(workspace_id: str = require_workspace) -> dict[str, Any]: + return ledger.statement(workspace_id) + + # -- proxy ------------------------------------------------------------------------------------ @app.exception_handler(InferrailError) - async def inferrail_error(_: Request, exc: InferrailError) -> JSONResponse: + async def inferrail_error(request: Request, exc: InferrailError) -> JSONResponse: status = next((s for t, s in _STATUS_BY_ERROR if isinstance(exc, t)), 500) code = code_for(exc) body = ErrorResponse( error=ErrorDetail( - message=str(exc), + message=_scrub(str(exc), request), type=type(exc).__name__, code=code.code, remediation=code.remediation, @@ -334,14 +461,19 @@ async def inferrail_error(_: Request, exc: InferrailError) -> JSONResponse: ) return JSONResponse(status_code=status, content=body.model_dump()) - def _admit(request: Request, workspace_id: str) -> tuple[dict[str, str], Decimal | None]: + @app.exception_handler(Exception) + async def unexpected_error(_: Request, exc: Exception) -> JSONResponse: + log.error("unhandled %s", type(exc).__name__) # never the message: it could hold a key + return JSONResponse({"detail": "internal error"}, status_code=500) + + def _admit(request: Request, workspace_id: str) -> tuple[dict[str, str], Decimal | None, Any]: provider_key = request.headers.get(PROVIDER_KEY_HEADER, "").strip() if not provider_key: raise HTTPException(400, "send your provider key in X-Provider-Api-Key") attributes = extract_attributes(request.headers) declared = extract_declared_budget(request.headers) work_id = attributes.setdefault("work_id", f"call_{uuid.uuid4().hex[:16]}") - admission = ledger.admit_run(workspace_id, work_id) + admission = ledger.hold_run(workspace_id, work_id) if not admission.admitted: raise HTTPException( status_code=402, @@ -352,62 +484,77 @@ def _admit(request: Request, workspace_id: str) -> tuple[dict[str, str], Decimal "purchase": price_sheet()["credits"], }, ) - return attributes, declared - - @app.post("/v1/chat/completions", response_model=None) - async def chat_completions( - payload: ChatCompletionRequest, request: Request, workspace_id: str = require_workspace - ) -> ChatCompletionResponse | StreamingResponse: - attributes, declared = _admit(request, workspace_id) - receipts, enforcer = stores.get(workspace_id) - provider = OpenAIProvider( - name="openai", - api_key=request.headers[PROVIDER_KEY_HEADER].strip(), - base_url="https://api.openai.com/v1", - is_verified_openai=True, - client=openai_client_factory(), - ) - engine = InferenceEngine( - Router(routes={}, default_provider="openai"), - {"openai": provider}, - NullTelemetrySink(), - pricing_resolver, - receipts, - budgets=enforcer, - ) + return attributes, declared, (workspace_id, admission.month, work_id) + + async def _run( + engine: Any, + provider: Any, + payload: Any, + request: Request, + workspace_id: str, + serialize: Any, + ) -> Any: + attributes, declared, hold = _admit(request, workspace_id) if payload.stream: try: body = await engine.prepare_stream( payload, attributes=attributes, declared_budget_usd=declared ) except BaseException: + ledger.finish_call(*hold, answered=False) await provider.aclose() raise - async def stream() -> Any: + async def stream() -> AsyncIterator[bytes]: try: async for chunk in body: yield chunk finally: + ledger.finish_call(*hold, answered=True) await provider.aclose() return StreamingResponse(stream(), media_type="text/event-stream") + answered = False try: - return await engine.execute( + result = await engine.execute( payload, attributes=attributes, declared_budget_usd=declared ) + answered = True + return serialize(result) finally: + ledger.finish_call(*hold, answered=answered) await provider.aclose() + @app.post("/v1/chat/completions", response_model=None) + async def chat_completions( + payload: ChatCompletionRequest, request: Request, workspace_id: str = require_workspace + ) -> Any: + receipts, enforcer = stores.get(workspace_id) + provider = OpenAIProvider( + name="openai", + api_key=request.headers.get(PROVIDER_KEY_HEADER, "").strip(), + base_url="https://api.openai.com/v1", + is_verified_openai=True, + client=openai_client_factory(), + ) + engine = InferenceEngine( + Router(routes={}, default_provider="openai"), + {"openai": provider}, + NullTelemetrySink(), + pricing_resolver, + receipts, + budgets=enforcer, + ) + return await _run(engine, provider, payload, request, workspace_id, lambda r: r) + @app.post("/v1/messages", response_model=None) async def messages( payload: MessagesRequest, request: Request, workspace_id: str = require_workspace ) -> Any: - attributes, declared = _admit(request, workspace_id) receipts, enforcer = stores.get(workspace_id) provider = AnthropicProvider( name="anthropic", - api_key=request.headers[PROVIDER_KEY_HEADER].strip(), + api_key=request.headers.get(PROVIDER_KEY_HEADER, "").strip(), base_url="https://api.anthropic.com/v1", client=anthropic_client_factory(), ) @@ -419,33 +566,15 @@ async def messages( receipts, budgets=enforcer, ) - if payload.stream: - try: - body = await engine.prepare_stream( - payload, attributes=attributes, declared_budget_usd=declared - ) - except BaseException: - await provider.aclose() - raise - - async def stream() -> Any: - try: - async for chunk in body: - yield chunk - finally: - await provider.aclose() - - return StreamingResponse(stream(), media_type="text/event-stream") - try: - result = await engine.execute( - payload, attributes=attributes, declared_budget_usd=declared - ) - finally: - await provider.aclose() - return JSONResponse( - result.model_dump( - mode="json", exclude={"usage": absent_cache_usage_fields(result.usage)} - ) + return await _run( + engine, + provider, + payload, + request, + workspace_id, + lambda r: JSONResponse( + r.model_dump(mode="json", exclude={"usage": absent_cache_usage_fields(r.usage)}) + ), ) @app.get("/v1/work/{work_id}") @@ -459,19 +588,32 @@ async def work(work_id: str, workspace_id: str = require_workspace) -> dict[str, "cost_usd": str(sum(priced, Decimal(0))), "unpriced_calls": len(rows) - len(priced), "unsuccessful_calls": sum(1 for r in rows if r.status != "success"), + "receipts": [ + { + "request_id": r.request_id, + "model": r.model, + "status": r.status, + "estimated_cost_usd": None + if r.estimated_cost_usd is None + else str(r.estimated_cost_usd), + } + for r in rows + ], } - # -- credits: card --------------------------------------------------------------------------- + # -- credits: card ---------------------------------------------------------------------------- @app.post("/v1/credits/checkout") async def checkout(request: Request, workspace_id: str = require_workspace) -> dict[str, str]: if settings.stripe is None: raise HTTPException(501, "card purchases are not enabled on this deployment") - packs = int(request.query_params.get("packs", "1")) - if not 1 <= packs <= 100: + packs_raw = request.query_params.get("packs", "1") + if not packs_raw.isdigit() or not 1 <= int(packs_raw) <= 100: raise HTTPException(400, "packs must be 1-100") async with stripe_client_factory() as client: - url = await stripe_checkout.create_session(settings.stripe, workspace_id, packs, client) - return {"checkout_url": url} + url = await stripe_checkout.create_session( + settings.stripe, workspace_id, int(packs_raw), client + ) + return {"checkout_url": url, "note": "Credits are added when Stripe confirms payment."} @app.post("/v1/stripe/webhook") async def stripe_webhook(request: Request) -> dict[str, Any]: @@ -484,22 +626,35 @@ async def stripe_webhook(request: Request) -> dict[str, Any]: ) except stripe_checkout.SignatureError as ex: raise HTTPException(400, f"invalid signature: {ex}") from ex + refund = stripe_checkout.refunded_charge(event) + if refund is not None: + intent, refunded = refund + reversed_runs = ledger.apply_refund("stripe", None, refunded, external_id=intent) + return {"refund_applied": reversed_runs is not None, "runs_reversed": reversed_runs} paid = stripe_checkout.paid_session(event) if paid is None: return {"granted": False, "reason": "not a paid checkout session"} - session_id, workspace_id, packs, amount = paid + session_id, workspace_id, packs, amount, intent = paid if amount != packs * settings.stripe.pack_price_cents: + log.error( + "checkout %s amount %d does not match the published price", session_id, amount + ) return {"granted": False, "reason": "amount does not match the published price"} granted = ledger.grant( - "stripe", session_id, workspace_id, packs * settings.stripe.pack_runs, amount + "stripe", + session_id, + workspace_id, + packs * settings.stripe.pack_runs, + amount, + external_id=intent, ) return {"granted": granted} @app.get("/v1/credits/thanks", response_class=PlainTextResponse) async def thanks() -> str: - return "Payment received. Credits appear once Stripe confirms it (usually seconds)." + return "Payment submitted. Credits appear once Stripe confirms it (usually seconds)." - # -- credits: x402 --------------------------------------------------------------------------- + # -- credits: x402 ---------------------------------------------------------------------------- if settings.x402_pay_to: from x402.http import HTTPFacilitatorClient from x402.http.middleware.fastapi import payment_middleware @@ -534,9 +689,9 @@ async def after_settle(context: Any) -> None: ledger.fail_pending("x402", ref) async def settle_failed(context: Any) -> None: - ref = _payment_ref(context.payment_payload) - if ref is not None: - ledger.fail_pending("x402", ref) + # A failed settle call is not proof the transfer didn't happen (timeouts). + # Reconciliation decides from the chain; nothing is granted here. + log.warning("x402 settlement failed; left pending for reconciliation") server.on_after_settle(after_settle) server.on_settle_failure(settle_failed) @@ -551,8 +706,8 @@ async def settle_failed(context: Any) -> None: resource=settings.public_base_url + "/v1/credits/x402", description=( f"{settings.x402_pack_runs:,} governed runs of Inferrail Hosted: per-run " - "dollar budgets for AI agents, enforced before the provider is called. " - "Send your workspace key as a Bearer token." + "dollar budgets for AI agents, reserved before every provider call. Send " + "your workspace key as a Bearer token." ), mime_type="application/json", service_name="Inferrail Hosted credits", @@ -570,10 +725,21 @@ async def buy_credits_x402( request: Request, workspace_id: str = require_workspace ) -> dict[str, Any]: header = request.headers.get("payment-signature") - ref = _payment_ref(decode_payment_signature_header(header)) if header else None - if ref is None: + payload = decode_payment_signature_header(header) if header else None + ref = _payment_ref(payload) if payload else None + auth = _authorization(payload) + if ref is None or not auth.get("validBefore"): raise HTTPException(400, "missing x402 payment") - ledger.record_pending("x402", ref, workspace_id, settings.x402_pack_runs, pack_cents) + ledger.record_pending( + "x402", + ref, + workspace_id, + settings.x402_pack_runs, + pack_cents, + authorizer=str(auth["from"]), + nonce=str(auth["nonce"]), + valid_before=int(auth["validBefore"]), + ) return { "workspace_id": workspace_id, "governed_runs": settings.x402_pack_runs, @@ -591,4 +757,7 @@ async def buy_credits_x402( host="0.0.0.0", port=int(os.environ.get("PORT", "8424")), proxy_headers=True, + # Client IPs (creation throttle) come from X-Forwarded-For only from these proxies. + forwarded_allow_ips=os.environ.get("BG_FORWARDED_ALLOW_IPS", "127.0.0.1"), + workers=1, # one process: see the deployment constraint above ) diff --git a/hosted/budget_gateway/stripe_checkout.py b/hosted/budget_gateway/stripe_checkout.py index 82ab8f0..32bae4e 100644 --- a/hosted/budget_gateway/stripe_checkout.py +++ b/hosted/budget_gateway/stripe_checkout.py @@ -60,9 +60,10 @@ def verify_event( return json.loads(raw_body) -def paid_session(event: dict[str, Any]) -> tuple[str, str, int, int] | None: - """(session_id, workspace_id, packs, amount_total_cents) for a paid Checkout session, else - None. `packs` comes from our own metadata, never from a client-supplied amount.""" +def paid_session(event: dict[str, Any]) -> tuple[str, str, int, int, str | None] | None: + """(session_id, workspace_id, packs, amount_total_cents, payment_intent) for a paid Checkout + session, else None. `packs` comes from our own metadata, never from a client-supplied + amount.""" if event.get("type") != "checkout.session.completed": return None session = (event.get("data") or {}).get("object") or {} @@ -72,7 +73,24 @@ def paid_session(event: dict[str, Any]) -> tuple[str, str, int, int] | None: workspace_id, packs = meta.get("workspace_id"), meta.get("packs") if not workspace_id or not str(packs).isdigit(): return None - return session["id"], workspace_id, int(packs), int(session.get("amount_total") or 0) + return ( + session["id"], + workspace_id, + int(packs), + int(session.get("amount_total") or 0), + session.get("payment_intent"), + ) + + +def refunded_charge(event: dict[str, Any]) -> tuple[str, int] | None: + """(payment_intent, cumulative amount_refunded_cents) for a `charge.refunded` event.""" + if event.get("type") != "charge.refunded": + return None + charge = (event.get("data") or {}).get("object") or {} + intent = charge.get("payment_intent") + if not intent: + return None + return str(intent), int(charge.get("amount_refunded") or 0) async def create_session( diff --git a/hosted/budget_gateway/workspaces.py b/hosted/budget_gateway/workspaces.py index 793f309..153096d 100644 --- a/hosted/budget_gateway/workspaces.py +++ b/hosted/budget_gateway/workspaces.py @@ -1,23 +1,31 @@ """Workspaces, governed-run metering and the credit ledger for Inferrail Hosted. One SQLite file holds every workspace's *billing* state. Each workspace's receipts and budgets -live in their own files (see `service.py`), exactly like `hosted/cost_gateway/tenant_store.py`. - -**Value metric.** A *governed run* is a distinct work id with at least one call through the -gateway in a UTC month. The first `free_runs_per_month` runs of each month are free. Every -later new run consumes one prepaid credit. Repeat calls inside an already-counted run cost -nothing more. - -**Money rules (each has a test):** - -- Credits are granted only from a confirmed payment and at most once per payment reference - (`UNIQUE(source, ref)`): a replayed webhook or settle hook can't grant twice. -- An x402 purchase is recorded as *pending* before settlement and becomes credits only when the - settle hook confirms success. A failed settlement grants nothing. A crash in between leaves a - visible pending row for `reconcile`, never a silent loss. -- Run admission and credit consumption happen in one `BEGIN IMMEDIATE` transaction, so - concurrent first calls for new runs can't spend the same credit twice. -- API keys are stored only as SHA-256 hashes. Provider keys never reach this module. +live in their own files (see `service.py`). + +**What is billed.** A *governed run* is a distinct work id in a UTC month. It becomes billable +only when at least one of its calls is answered by the provider. While its first calls are in +flight, the run *holds* one unit (a free-allowance slot, or one prepaid credit past the +allowance), so concurrent new runs can't overspend. If every call of the run fails or is refused +by its own budget before the provider answers, the hold is released and the run costs nothing. +Later calls in a billed run cost nothing more. + +**Financial states (auditable; each has a test):** + +| Table | State | Meaning | +|---|---|---| +| `governed_runs` | `held` | A unit reserved by in-flight calls (`inflight` > 0) | +| `governed_runs` | `billed` | At least one answered call. Permanent for the month | +| `pending_purchases` | `pending` | x402 payment seen by the handler, not yet confirmed | +| `pending_purchases` | `settled` / `failed` | Decided by the settle hook or by the chain | +| `credit_grants` | — | Credits from one confirmed payment. `UNIQUE(source, ref)`: never twice | +| `credit_reversals` | — | Refunded credits for one grant. Monotonic: a replay is a no-op | + +Balance = grants − reversals − (held + billed runs that needed a credit). A refund can make the +balance negative. Paid runs are then refused until it is topped up, so a customer can never +spend credits they don't have. + +Workspace API keys are stored only as SHA-256 hashes. Provider keys never reach this module. """ from __future__ import annotations @@ -27,6 +35,7 @@ import sqlite3 import threading import time +from collections.abc import Callable from dataclasses import dataclass from pathlib import Path @@ -45,6 +54,8 @@ work_id TEXT NOT NULL, first_seen INTEGER NOT NULL, paid INTEGER NOT NULL, + state TEXT NOT NULL CHECK (state IN ('held', 'billed')), + inflight INTEGER NOT NULL, PRIMARY KEY (workspace_id, month, work_id) ); CREATE TABLE IF NOT EXISTS credit_grants ( @@ -53,15 +64,29 @@ workspace_id TEXT NOT NULL, runs INTEGER NOT NULL CHECK (runs > 0), amount_usd_cents INTEGER NOT NULL, + external_id TEXT, granted_at INTEGER NOT NULL, PRIMARY KEY (source, ref) ); +CREATE INDEX IF NOT EXISTS credit_grants_external ON credit_grants(source, external_id); +CREATE TABLE IF NOT EXISTS credit_reversals ( + source TEXT NOT NULL, + ref TEXT NOT NULL, + workspace_id TEXT NOT NULL, + runs INTEGER NOT NULL CHECK (runs >= 0), + amount_usd_cents INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (source, ref) +); CREATE TABLE IF NOT EXISTS pending_purchases ( source TEXT NOT NULL, ref TEXT NOT NULL, workspace_id TEXT NOT NULL, runs INTEGER NOT NULL, amount_usd_cents INTEGER NOT NULL, + authorizer TEXT NOT NULL, + nonce TEXT NOT NULL, + valid_before INTEGER NOT NULL, created_at INTEGER NOT NULL, state TEXT NOT NULL CHECK (state IN ('pending', 'settled', 'failed')), PRIMARY KEY (source, ref) @@ -80,7 +105,7 @@ def hash_key(api_key: str) -> str: @dataclass(frozen=True) class Admission: admitted: bool - new_run: bool + month: str paid: bool month_runs: int credits_remaining: int @@ -90,6 +115,7 @@ class Admission: class Usage: month: str month_runs: int + billed_runs: int free_runs_per_month: int free_runs_remaining: int credits_remaining: int @@ -102,10 +128,14 @@ def __init__(self, path: Path, *, free_runs_per_month: int) -> None: str(path), isolation_level=None, check_same_thread=False, timeout=30 ) self._db.execute("PRAGMA journal_mode=WAL") + self._db.execute("PRAGMA synchronous=FULL") self._db.executescript(SCHEMA) - self._lock = threading.Lock() + self._lock = threading.RLock() self.free_runs_per_month = free_runs_per_month + def _tx(self) -> _Tx: + return _Tx(self) + # -- workspaces --------------------------------------------------------------------------- def create_workspace(self) -> tuple[str, str]: @@ -119,156 +149,357 @@ def create_workspace(self) -> tuple[str, str]: ) return workspace_id, api_key + def _read(self, sql: str, params: tuple[object, ...] = ()) -> list[tuple[object, ...]]: + """Reads share the writers' lock: one sqlite3 connection must never be used by two + threads at once.""" + with self._lock: + return list(self._db.execute(sql, params).fetchall()) + def authenticate(self, api_key: str) -> str | None: if not api_key.startswith(KEY_PREFIX): return None - row = self._db.execute( + rows = self._read( "SELECT workspace_id FROM workspaces WHERE key_hash=? AND disabled=0", (hash_key(api_key),), - ).fetchone() - return row[0] if row else None + ) + return str(rows[0][0]) if rows else None def count_workspaces(self) -> int: - return self._db.execute("SELECT COUNT(*) FROM workspaces").fetchone()[0] + return int(self._read("SELECT COUNT(*) FROM workspaces")[0][0]) # type: ignore[call-overload] # -- metering ----------------------------------------------------------------------------- def _credits_remaining(self, workspace_id: str) -> int: granted = self._db.execute( - "SELECT COALESCE(SUM(runs),0) FROM credit_grants WHERE workspace_id=?", (workspace_id,) + "SELECT COALESCE(SUM(runs),0) FROM credit_grants WHERE workspace_id=?", + (workspace_id,), + ).fetchone()[0] + reversed_ = self._db.execute( + "SELECT COALESCE(SUM(runs),0) FROM credit_reversals WHERE workspace_id=?", + (workspace_id,), ).fetchone()[0] consumed = self._db.execute( "SELECT COUNT(*) FROM governed_runs WHERE workspace_id=? AND paid=1", (workspace_id,) ).fetchone()[0] - return granted - consumed + return int(granted - reversed_ - consumed) - def admit_run(self, workspace_id: str, work_id: str, now: float | None = None) -> Admission: - """Count `work_id` as a governed run (once per month), spending a credit past the free - allowance. Refuses (admitted=False) when the allowance and credits are exhausted.""" + def _month_runs(self, workspace_id: str, month: str) -> int: + return int( + self._db.execute( + "SELECT COUNT(*) FROM governed_runs WHERE workspace_id=? AND month=?", + (workspace_id, month), + ).fetchone()[0] + ) + + def hold_run(self, workspace_id: str, work_id: str, now: float | None = None) -> Admission: + """Admit one call of `work_id`. A new run takes a free slot, or a credit past the free + allowance. Refuses (admitted=False) when neither is available. Every admitted call must + be followed by exactly one `finish_call`.""" now = time.time() if now is None else now month = month_of(now) - with self._lock: - self._db.execute("BEGIN IMMEDIATE") - try: - runs = self._db.execute( - "SELECT COUNT(*) FROM governed_runs WHERE workspace_id=? AND month=?", - (workspace_id, month), - ).fetchone()[0] - credits = self._credits_remaining(workspace_id) - known = self._db.execute( - "SELECT 1 FROM governed_runs WHERE workspace_id=? AND month=? AND work_id=?", + with self._tx(): + runs = self._month_runs(workspace_id, month) + credits = self._credits_remaining(workspace_id) + known = self._db.execute( + "SELECT paid FROM governed_runs WHERE workspace_id=? AND month=? AND work_id=?", + (workspace_id, month, work_id), + ).fetchone() + if known: + self._db.execute( + "UPDATE governed_runs SET inflight=inflight+1 " + "WHERE workspace_id=? AND month=? AND work_id=?", (workspace_id, month, work_id), - ).fetchone() - if known: - self._db.execute("COMMIT") - return Admission(True, False, False, runs, credits) - paid = runs >= self.free_runs_per_month - if paid and credits <= 0: - self._db.execute("COMMIT") - return Admission(False, False, False, runs, credits) + ) + return Admission(True, month, bool(known[0]), runs, credits) + paid = runs >= self.free_runs_per_month + if paid and credits <= 0: + return Admission(False, month, True, runs, credits) + self._db.execute( + "INSERT INTO governed_runs VALUES (?,?,?,?,?,'held',1)", + (workspace_id, month, work_id, int(now), int(paid)), + ) + return Admission(True, month, paid, runs + 1, credits - int(paid)) + + def finish_call(self, workspace_id: str, month: str, work_id: str, answered: bool) -> None: + """Close one admitted call. An answered call bills the run. An unanswered last in-flight + call of a never-billed run releases its hold (free slot or credit returns).""" + with self._tx(): + if answered: self._db.execute( - "INSERT INTO governed_runs VALUES (?,?,?,?,?)", - (workspace_id, month, work_id, int(now), int(paid)), + "UPDATE governed_runs SET state='billed', inflight=MAX(inflight-1,0) " + "WHERE workspace_id=? AND month=? AND work_id=?", + (workspace_id, month, work_id), ) - self._db.execute("COMMIT") - return Admission(True, True, paid, runs + 1, credits - int(paid)) - except BaseException: - self._db.execute("ROLLBACK") - raise + return + self._db.execute( + "UPDATE governed_runs SET inflight=MAX(inflight-1,0) " + "WHERE workspace_id=? AND month=? AND work_id=?", + (workspace_id, month, work_id), + ) + self._db.execute( + "DELETE FROM governed_runs WHERE workspace_id=? AND month=? AND work_id=? " + "AND state='held' AND inflight=0", + (workspace_id, month, work_id), + ) + + def release_orphaned_holds(self) -> int: + """At startup: a held run with no live process behind it (crash mid-call) is released. + Billed runs are kept. Only safe when no other instance is running (single-instance + lock).""" + with self._tx(): + cur = self._db.execute("DELETE FROM governed_runs WHERE state='held'") + self._db.execute("UPDATE governed_runs SET inflight=0") + return int(cur.rowcount) def usage(self, workspace_id: str, now: float | None = None) -> Usage: - month = month_of(time.time() if now is None else now) - runs = self._db.execute( - "SELECT COUNT(*) FROM governed_runs WHERE workspace_id=? AND month=?", - (workspace_id, month), - ).fetchone()[0] - return Usage( - month, - runs, - self.free_runs_per_month, - max(self.free_runs_per_month - runs, 0), - self._credits_remaining(workspace_id), - ) + with self._lock: + month = month_of(time.time() if now is None else now) + runs = self._month_runs(workspace_id, month) + billed = int( + self._db.execute( + "SELECT COUNT(*) FROM governed_runs WHERE workspace_id=? AND month=? " + "AND state='billed'", + (workspace_id, month), + ).fetchone()[0] + ) + return Usage( + month, + runs, + billed, + self.free_runs_per_month, + max(self.free_runs_per_month - runs, 0), + self._credits_remaining(workspace_id), + ) # -- credits ------------------------------------------------------------------------------ def grant( - self, source: str, ref: str, workspace_id: str, runs: int, amount_usd_cents: int + self, + source: str, + ref: str, + workspace_id: str, + runs: int, + amount_usd_cents: int, + external_id: str | None = None, ) -> bool: - """Grant credits for one confirmed payment. Returns False if `(source, ref)` was already - granted (replay), True if this call granted.""" - with self._lock: + """Grant credits for one confirmed payment. False if `(source, ref)` was already granted.""" + with self._tx(): cur = self._db.execute( - "INSERT OR IGNORE INTO credit_grants VALUES (?,?,?,?,?,?)", - (source, ref, workspace_id, runs, amount_usd_cents, int(time.time())), + "INSERT OR IGNORE INTO credit_grants VALUES (?,?,?,?,?,?,?)", + (source, ref, workspace_id, runs, amount_usd_cents, external_id, int(time.time())), ) return cur.rowcount == 1 + def apply_refund( + self, source: str, ref: str | None, refunded_cents: int, *, external_id: str | None = None + ) -> int | None: + """Reverse credits for a refunded grant, proportionally to the refunded amount. The + reversal is monotonic (never decreases), so replays and out-of-order events are safe. + Returns the total runs reversed for the grant, or None if no matching grant exists.""" + with self._tx(): + if ref is None: + row = self._db.execute( + "SELECT ref, workspace_id, runs, amount_usd_cents FROM credit_grants " + "WHERE source=? AND external_id=?", + (source, external_id), + ).fetchone() + else: + row = self._db.execute( + "SELECT ref, workspace_id, runs, amount_usd_cents FROM credit_grants " + "WHERE source=? AND ref=?", + (source, ref), + ).fetchone() + if row is None: + return None + grant_ref, workspace_id, runs, amount = row + refunded = min(max(refunded_cents, 0), amount) + target = runs if refunded >= amount else (runs * refunded) // max(amount, 1) + self._db.execute( + "INSERT INTO credit_reversals VALUES (?,?,?,?,?,?) " + "ON CONFLICT(source, ref) DO UPDATE SET " + "runs=MAX(runs, excluded.runs), " + "amount_usd_cents=MAX(amount_usd_cents, excluded.amount_usd_cents), " + "updated_at=excluded.updated_at", + (source, grant_ref, workspace_id, target, refunded, int(time.time())), + ) + total = self._db.execute( + "SELECT runs FROM credit_reversals WHERE source=? AND ref=?", (source, grant_ref) + ).fetchone()[0] + return int(total) + def record_pending( - self, source: str, ref: str, workspace_id: str, runs: int, amount_usd_cents: int + self, + source: str, + ref: str, + workspace_id: str, + runs: int, + amount_usd_cents: int, + *, + authorizer: str, + nonce: str, + valid_before: int, ) -> None: - with self._lock: + with self._tx(): self._db.execute( - "INSERT OR IGNORE INTO pending_purchases VALUES (?,?,?,?,?,?,'pending')", - (source, ref, workspace_id, runs, amount_usd_cents, int(time.time())), + "INSERT OR IGNORE INTO pending_purchases VALUES (?,?,?,?,?,?,?,?,?,'pending')", + ( + source, + ref, + workspace_id, + runs, + amount_usd_cents, + authorizer, + nonce, + valid_before, + int(time.time()), + ), ) def settle_pending(self, source: str, ref: str) -> bool: - """Turn a pending purchase into credits after confirmed settlement. Idempotent.""" - with self._lock: - self._db.execute("BEGIN IMMEDIATE") - try: - row = self._db.execute( - "SELECT workspace_id, runs, amount_usd_cents, state FROM pending_purchases " - "WHERE source=? AND ref=?", - (source, ref), - ).fetchone() - if row is None: - self._db.execute("COMMIT") - return False - workspace_id, runs, cents, _state = row - cur = self._db.execute( - "INSERT OR IGNORE INTO credit_grants VALUES (?,?,?,?,?,?)", - (source, ref, workspace_id, runs, cents, int(time.time())), - ) - self._db.execute( - "UPDATE pending_purchases SET state='settled' WHERE source=? AND ref=?", - (source, ref), - ) - self._db.execute("COMMIT") - return cur.rowcount == 1 - except BaseException: - self._db.execute("ROLLBACK") - raise + """Turn a pending purchase into credits after confirmed settlement. Idempotent: True only + for the call that actually granted.""" + with self._tx(): + row = self._db.execute( + "SELECT workspace_id, runs, amount_usd_cents FROM pending_purchases " + "WHERE source=? AND ref=?", + (source, ref), + ).fetchone() + if row is None: + return False + workspace_id, runs, cents = row + cur = self._db.execute( + "INSERT OR IGNORE INTO credit_grants VALUES (?,?,?,?,?,?,?)", + (source, ref, workspace_id, runs, cents, None, int(time.time())), + ) + self._db.execute( + "UPDATE pending_purchases SET state='settled' WHERE source=? AND ref=?", + (source, ref), + ) + return cur.rowcount == 1 def fail_pending(self, source: str, ref: str) -> None: - with self._lock: + """Mark a pending purchase failed. Never overrides a confirmed settlement.""" + with self._tx(): self._db.execute( "UPDATE pending_purchases SET state='failed' " "WHERE source=? AND ref=? AND state='pending'", (source, ref), ) - def pending(self) -> list[dict]: - return [ - dict( - zip( - ("source", "ref", "workspace_id", "runs", "amount_usd_cents", "created_at"), - r, - strict=True, - ) - ) - for r in self._db.execute( - "SELECT source, ref, workspace_id, runs, amount_usd_cents, created_at " - "FROM pending_purchases WHERE state='pending' ORDER BY created_at" + def pending(self) -> list[dict[str, object]]: + with self._lock: + cols = ( + "source", + "ref", + "workspace_id", + "runs", + "amount_usd_cents", + "authorizer", + "nonce", + "valid_before", + "created_at", ) - ] + return [ + dict(zip(cols, r, strict=True)) + for r in self._db.execute( + f"SELECT {', '.join(cols)} FROM pending_purchases " + "WHERE state='pending' ORDER BY created_at" + ) + ] + + def reconcile_pending( + self, + authorization_used: Callable[[str, str], bool | None], + now: float | None = None, + grace_seconds: int = 120, + ) -> dict[str, int]: + """Resolve pending x402 purchases from chain truth. + `authorization_used(authorizer, nonce)` returns True (the EIP-3009 authorization was + used: the payment settled), False (not used), or None (unknown: RPC error). + - Used → credits granted (idempotent). + - Not used and past `valid_before` + grace → failed: it can never settle now. + - Unknown, or not yet expired → left pending.""" + now = time.time() if now is None else now + out = {"settled": 0, "failed": 0, "unresolved": 0} + for p in self.pending(): + used = authorization_used(str(p["authorizer"]), str(p["nonce"])) + if used is True: + self.settle_pending(str(p["source"]), str(p["ref"])) + out["settled"] += 1 + elif used is False and now > int(str(p["valid_before"])) + grace_seconds: + self.fail_pending(str(p["source"]), str(p["ref"])) + out["failed"] += 1 + else: + out["unresolved"] += 1 + return out + + # -- reporting ---------------------------------------------------------------------------- + + def statement(self, workspace_id: str) -> dict[str, object]: + with self._lock: + grants = [ + {"source": s, "ref": r, "runs": n, "amount_usd_cents": c, "granted_at": t} + for s, r, n, c, t in self._db.execute( + "SELECT source, ref, runs, amount_usd_cents, granted_at FROM credit_grants " + "WHERE workspace_id=? ORDER BY granted_at", + (workspace_id,), + ) + ] + reversals = [ + {"source": s, "ref": r, "runs": n, "amount_usd_cents": c} + for s, r, n, c in self._db.execute( + "SELECT source, ref, runs, amount_usd_cents FROM credit_reversals " + "WHERE workspace_id=?", + (workspace_id,), + ) + ] + pending = [ + {"source": s, "ref": r, "runs": n, "amount_usd_cents": c} + for s, r, n, c in self._db.execute( + "SELECT source, ref, runs, amount_usd_cents FROM pending_purchases " + "WHERE workspace_id=? AND state='pending'", + (workspace_id,), + ) + ] + return { + "grants": grants, + "reversals": reversals, + "pending_purchases": pending, + "credits_remaining": self._credits_remaining(workspace_id), + } def revenue(self) -> dict[str, int]: - """Granted (i.e. confirmed-paid) cents by source. Operator metric.""" - return { - s: c - for s, c in self._db.execute( - "SELECT source, SUM(amount_usd_cents) FROM credit_grants GROUP BY source" + """Confirmed-paid cents by source, net of refunds. Operator metric.""" + with self._lock: + gross = dict( + self._db.execute( + "SELECT source, SUM(amount_usd_cents) FROM credit_grants GROUP BY source" + ).fetchall() + ) + refunds = dict( + self._db.execute( + "SELECT source, SUM(amount_usd_cents) FROM credit_reversals GROUP BY source" + ).fetchall() ) - } + return {k: int(v) - int(refunds.get(k, 0)) for k, v in gross.items()} + + +class _Tx: + """`BEGIN IMMEDIATE` under the process lock: one writer at a time, all-or-nothing.""" + + def __init__(self, ledger: WorkspaceLedger) -> None: + self._l = ledger + + def __enter__(self) -> None: + self._l._lock.acquire() + try: + self._l._db.execute("BEGIN IMMEDIATE") + except BaseException: + self._l._lock.release() + raise + + def __exit__(self, exc_type: object, exc: object, tb: object) -> None: + try: + self._l._db.execute("ROLLBACK" if exc_type else "COMMIT") + finally: + self._l._lock.release() diff --git a/tests/unit/hosted/test_budget_gateway.py b/tests/unit/hosted/test_budget_gateway.py index 428c474..8e0b09f 100644 --- a/tests/unit/hosted/test_budget_gateway.py +++ b/tests/unit/hosted/test_budget_gateway.py @@ -1,5 +1,16 @@ -"""Tests for hosted/budget_gateway (Inferrail Hosted): workspaces, governed-run metering, the -credit ledger, Stripe webhook verification, provider-key pass-through and the x402 credit rail. +"""Tests for hosted/budget_gateway (Inferrail Hosted). + +Covered: +- governed-run metering and billing-on-answer; +- the credit ledger's financial states; +- Stripe verification, replay, interruption and refunds; +- x402 settlement, failure, crash-before-grant and on-chain reconciliation; +- provider-key handling; +- concurrency; +- workspace isolation; +- restart recovery; +- the single-instance constraint; +- the complete customer journey. Providers are `httpx.MockTransport` (no network, no key). The x402 rail uses the real x402 2.22.0 middleware with an offline fake facilitator (`_x402_fake_facilitator.py`). @@ -7,6 +18,7 @@ from __future__ import annotations +import asyncio import hashlib import hmac import importlib.util @@ -36,6 +48,7 @@ HOSTED = Path(__file__).resolve().parents[3] / "hosted" / "budget_gateway" PROVIDER_KEY = "sk-test-provider-key-0123456789abcdef" PAY_TO = "0x" + "9" * 40 +T = 1_791_000_000 # 2026-10 def _load(name: str, filename: str) -> ModuleType: @@ -51,6 +64,7 @@ def _load(name: str, filename: str) -> ModuleType: def mods(): _load("workspaces", "workspaces.py") _load("stripe_checkout", "stripe_checkout.py") + _load("chain_check", "chain_check.py") return _load("budget_gateway_service", "service.py") @@ -60,90 +74,173 @@ def ledger(tmp_path): return ws.WorkspaceLedger(tmp_path / "w.sqlite3", free_runs_per_month=2) -# -- ledger --------------------------------------------------------------------------------------- +def _bill(ledger, ws, work, t=T): + a = ledger.hold_run(ws, work, t) + if a.admitted: + ledger.finish_call(ws, a.month, work, answered=True) + return a -def test_free_allowance_then_refusal_and_repeat_runs_are_free(ledger): +# == ledger: metering ============================================================================= + + +def test_free_allowance_then_refusal_and_repeat_calls_in_a_run_are_free(ledger): ws, _ = ledger.create_workspace() - t = 1_791_000_000 - assert ledger.admit_run(ws, "a", t).admitted - assert ledger.admit_run(ws, "b", t).admitted - again = ledger.admit_run(ws, "a", t) - assert again.admitted and not again.new_run - refused = ledger.admit_run(ws, "c", t) + assert _bill(ledger, ws, "a").admitted + assert _bill(ledger, ws, "b").admitted + assert _bill(ledger, ws, "a").admitted # same run again + refused = ledger.hold_run(ws, "c", T) assert not refused.admitted and refused.month_runs == 2 + assert ledger.usage(ws, T).billed_runs == 2 def test_allowance_resets_each_utc_month(ledger): ws, _ = ledger.create_workspace() for w in ("a", "b"): - ledger.admit_run(ws, w, 1_790_000_000) # 2026-09 - assert not ledger.admit_run(ws, "c", 1_790_000_000).admitted - assert ledger.admit_run(ws, "c", 1_792_700_000).admitted # 2026-10 + _bill(ledger, ws, w, 1_790_000_000) # 2026-09 + assert not ledger.hold_run(ws, "c", 1_790_000_000).admitted + assert ledger.hold_run(ws, "c", 1_792_700_000).admitted # 2026-10 -def test_credits_are_spent_past_the_allowance_and_then_exhaust(ledger): +def test_a_run_whose_calls_are_never_answered_costs_nothing(ledger): ws, _ = ledger.create_workspace() - t = 1_791_000_000 - ledger.admit_run(ws, "a", t) - ledger.admit_run(ws, "b", t) - assert ledger.grant("stripe", "cs_1", ws, 1, 100) - paid = ledger.admit_run(ws, "c", t) - assert paid.admitted and paid.paid and paid.credits_remaining == 0 - assert not ledger.admit_run(ws, "d", t).admitted + ledger.grant("stripe", "cs_1", ws, 1, 100) + _bill(ledger, ws, "a") + _bill(ledger, ws, "b") + held = ledger.hold_run(ws, "c", T) # takes the last credit + assert held.admitted and held.paid and held.credits_remaining == 0 + ledger.finish_call(ws, held.month, "c", answered=False) # refused or provider error + assert ledger.usage(ws, T).credits_remaining == 1 # credit returned + assert ledger.usage(ws, T).month_runs == 2 -def test_a_payment_reference_grants_at_most_once(ledger): +def test_a_hold_is_kept_while_another_call_of_the_run_is_in_flight(ledger): ws, _ = ledger.create_workspace() - assert ledger.grant("stripe", "cs_1", ws, 10, 1000) - assert not ledger.grant("stripe", "cs_1", ws, 10, 1000) - assert ledger.usage(ws).credits_remaining == 10 - assert ledger.revenue() == {"stripe": 1000} + a1 = ledger.hold_run(ws, "r", T) + ledger.hold_run(ws, "r", T) # second concurrent call in the same run + ledger.finish_call(ws, a1.month, "r", answered=False) # first fails + assert ledger.usage(ws, T).month_runs == 1 # still held by the second + ledger.finish_call(ws, a1.month, "r", answered=True) + assert ledger.usage(ws, T).billed_runs == 1 def test_concurrent_new_runs_never_spend_one_credit_twice(ledger): ws, _ = ledger.create_workspace() - t = 1_791_000_000 - ledger.admit_run(ws, "a", t) - ledger.admit_run(ws, "b", t) + _bill(ledger, ws, "a") + _bill(ledger, ws, "b") ledger.grant("x402", "r1", ws, 1, 100) - results = [] + results: list[bool] = [] threads = [ threading.Thread( - target=lambda i=i: results.append(ledger.admit_run(ws, f"run-{i}", t).admitted) + target=lambda i=i: results.append(ledger.hold_run(ws, f"run-{i}", T).admitted) ) - for i in range(20) + for i in range(25) ] for th in threads: th.start() for th in threads: th.join() assert results.count(True) == 1 - assert ledger.usage(ws, t).credits_remaining == 0 + assert ledger.usage(ws, T).credits_remaining == 0 -def test_pending_purchase_becomes_credits_only_on_settlement(ledger): +def test_restart_releases_orphaned_holds_but_keeps_billed_runs(tmp_path, ledger): ws, _ = ledger.create_workspace() - ledger.record_pending("x402", "0xabc:0x1", ws, 1000, 100) - assert ledger.usage(ws).credits_remaining == 0 - assert [p["ref"] for p in ledger.pending()] == ["0xabc:0x1"] - assert ledger.settle_pending("x402", "0xabc:0x1") - assert not ledger.settle_pending("x402", "0xabc:0x1") - assert ledger.usage(ws).credits_remaining == 1000 and ledger.pending() == [] - ledger.record_pending("x402", "0xabc:0x2", ws, 1000, 100) - ledger.fail_pending("x402", "0xabc:0x2") - assert ledger.usage(ws).credits_remaining == 1000 and ledger.pending() == [] + _bill(ledger, ws, "billed") + ledger.hold_run(ws, "in-flight-at-crash", T) + reopened = type(ledger)(tmp_path / "w.sqlite3", free_runs_per_month=2) + assert reopened.release_orphaned_holds() == 1 + u = reopened.usage(ws, T) + assert u.month_runs == 1 and u.billed_runs == 1 def test_workspace_keys_are_stored_only_as_hashes(tmp_path, ledger): _, key = ledger.create_workspace() assert ledger.authenticate(key) is not None assert ledger.authenticate(key + "x") is None + assert ledger.authenticate("not-a-key") is None blob = b"".join(p.read_bytes() for p in tmp_path.iterdir()) assert key.encode() not in blob -# -- stripe --------------------------------------------------------------------------------------- +# == ledger: money ================================================================================ + + +def test_a_payment_reference_grants_at_most_once(ledger): + ws, _ = ledger.create_workspace() + assert ledger.grant("stripe", "cs_1", ws, 10, 1000, external_id="pi_1") + assert not ledger.grant("stripe", "cs_1", ws, 10, 1000, external_id="pi_1") + assert ledger.usage(ws).credits_remaining == 10 + assert ledger.revenue() == {"stripe": 1000} + + +def test_pending_purchase_becomes_credits_only_on_settlement(ledger): + ws, _ = ledger.create_workspace() + kw = {"authorizer": "0xabc", "nonce": "0x1", "valid_before": int(time.time()) + 600} + ledger.record_pending("x402", "0xabc:0x1", ws, 1000, 100, **kw) + assert ledger.usage(ws).credits_remaining == 0 + assert ledger.settle_pending("x402", "0xabc:0x1") + assert not ledger.settle_pending("x402", "0xabc:0x1") + assert ledger.usage(ws).credits_remaining == 1000 and ledger.pending() == [] + ledger.fail_pending("x402", "0xabc:0x1") # a late failure never undoes a settlement + assert ledger.usage(ws).credits_remaining == 1000 + + +def test_reconciliation_resolves_pending_purchases_from_chain_truth(ledger): + ws, _ = ledger.create_workspace() + now = time.time() + ledger.record_pending( + "x402", "p:used", ws, 1000, 100, authorizer="0xa", nonce="0x1", valid_before=int(now) + 600 + ) + ledger.record_pending( + "x402", + "p:expired", + ws, + 1000, + 100, + authorizer="0xa", + nonce="0x2", + valid_before=int(now) - 1000, + ) + ledger.record_pending( + "x402", "p:live", ws, 1000, 100, authorizer="0xa", nonce="0x3", valid_before=int(now) + 600 + ) + ledger.record_pending( + "x402", + "p:rpc-down", + ws, + 1000, + 100, + authorizer="0xa", + nonce="0x4", + valid_before=int(now) - 1000, + ) + chain = {"0x1": True, "0x2": False, "0x3": False, "0x4": None} + out = ledger.reconcile_pending(lambda _a, n: chain[n], now=now) + assert out == {"settled": 1, "failed": 1, "unresolved": 2} + assert ledger.usage(ws).credits_remaining == 1000 + assert {p["ref"] for p in ledger.pending()} == {"p:live", "p:rpc-down"} + again = ledger.reconcile_pending(lambda _a, n: chain[n], now=now) + assert again["settled"] == 0 and ledger.usage(ws).credits_remaining == 1000 + + +def test_refunds_reverse_credits_monotonically_and_block_negative_balances(ledger): + ws, _ = ledger.create_workspace() + ledger.grant("stripe", "cs_1", ws, 10, 1000, external_id="pi_1") + assert ledger.apply_refund("stripe", None, 500, external_id="pi_1") == 5 + assert ledger.apply_refund("stripe", None, 500, external_id="pi_1") == 5 # replay + assert ledger.apply_refund("stripe", None, 300, external_id="pi_1") == 5 # out of order + assert ledger.usage(ws).credits_remaining == 5 + for w in ("a", "b", "c", "d", "e", "f", "g"): # 2 free + 5 paid + assert _bill(ledger, ws, w).admitted + assert ledger.apply_refund("stripe", None, 1000, external_id="pi_1") == 10 # full refund + assert ledger.usage(ws).credits_remaining == -5 + assert not ledger.hold_run(ws, "h", T).admitted # no spending a negative balance + assert ledger.apply_refund("stripe", None, 100, external_id="pi_unknown") is None + assert ledger.revenue() == {"stripe": 0} + + +# == stripe ======================================================================================= def _sign(body: bytes, secret: str, ts: int | None = None) -> str: @@ -157,7 +254,7 @@ def test_stripe_signature_verification(mods): body = b'{"type":"x"}' assert sc.verify_event(body, _sign(body, "whsec_1"), "whsec_1") == {"type": "x"} stale = _sign(body, "whsec_1", ts=int(time.time()) - 3600) - for header in (_sign(body, "other"), stale, "v1=x"): + for header in (_sign(body, "other"), stale, "v1=x", ""): with pytest.raises(sc.SignatureError): sc.verify_event(body, header, "whsec_1") with pytest.raises(sc.SignatureError): @@ -167,8 +264,8 @@ def test_stripe_signature_verification(mods): STRIPE_ENV = {"STRIPE_SECRET_KEY": "sk_test_x", "STRIPE_WEBHOOK_SECRET": "whsec_1"} -def _client(mods, tmp_path, env=None, facilitator=None, **extra): - settings = mods.Settings.from_env( +def _settings(mods, tmp_path, env=None, **extra): + return mods.Settings.from_env( { "BG_DATA_DIR": str(tmp_path / "data"), "BG_FREE_RUNS_PER_MONTH": "2", @@ -176,7 +273,13 @@ def _client(mods, tmp_path, env=None, facilitator=None, **extra): **extra, } ) - return TestClient(mods.create_app(settings, facilitator=facilitator)) + + +def _client(mods, tmp_path, env=None, facilitator=None, **extra): + return TestClient( + mods.create_app(_settings(mods, tmp_path, env, **extra), facilitator=facilitator), + raise_server_exceptions=False, + ) def _workspace(client): @@ -185,7 +288,15 @@ def _workspace(client): return r.json()["api_key"] -def _event(workspace, packs=1, amount=1000, status="paid", sid="cs_test_1"): +def _auth(key): + return {"Authorization": f"Bearer {key}"} + + +def _ws_id(c, key): + return c.get("/v1/workspace", headers=_auth(key)).json()["workspace_id"] + + +def _session_event(workspace, packs=1, amount=1000, status="paid", sid="cs_test_1"): return json.dumps( { "type": "checkout.session.completed", @@ -195,6 +306,7 @@ def _event(workspace, packs=1, amount=1000, status="paid", sid="cs_test_1"): "mode": "payment", "payment_status": status, "amount_total": amount, + "payment_intent": "pi_test_1", "metadata": {"workspace_id": workspace, "packs": str(packs)}, } }, @@ -202,42 +314,129 @@ def _event(workspace, packs=1, amount=1000, status="paid", sid="cs_test_1"): ).encode() +def _refund_event(refunded): + return json.dumps( + { + "type": "charge.refunded", + "data": {"object": {"payment_intent": "pi_test_1", "amount_refunded": refunded}}, + } + ).encode() + + +def _post_event(c, body, header=None): + return c.post( + "/v1/stripe/webhook", + content=body, + headers={"Stripe-Signature": header or _sign(body, "whsec_1")}, + ) + + def test_stripe_webhook_grants_once_and_only_for_paid_matching_sessions(mods, tmp_path): c = _client(mods, tmp_path, STRIPE_ENV) key = _workspace(c) - ws = c.get("/v1/workspace", headers={"Authorization": f"Bearer {key}"}).json()["workspace_id"] + ws = _ws_id(c, key) + assert _post_event(c, _session_event(ws, status="unpaid")).json()["granted"] is False + assert _post_event(c, _session_event(ws, amount=1)).json()["granted"] is False + bad = _session_event(ws) + assert _post_event(c, bad, header=_sign(bad, "wrong")).status_code == 400 + assert _post_event(c, _session_event(ws)).json()["granted"] is True + assert _post_event(c, _session_event(ws)).json()["granted"] is False # replay + assert c.get("/v1/workspace", headers=_auth(key)).json()["credits_remaining"] == 10_000 - def post(body, header=None): - return c.post( - "/v1/stripe/webhook", - content=body, - headers={"Stripe-Signature": header or _sign(body, "whsec_1")}, - ) - assert post(_event(ws, status="unpaid")).json()["granted"] is False - assert post(_event(ws, amount=1)).json()["granted"] is False - assert post(_event(ws), header=_sign(_event(ws), "wrong")).status_code == 400 - assert post(_event(ws)).json()["granted"] is True - assert post(_event(ws)).json()["granted"] is False # replay - usage = c.get("/v1/workspace", headers={"Authorization": f"Bearer {key}"}).json() - assert usage["credits_remaining"] == 10_000 +def test_checkout_redirect_alone_grants_nothing(mods, tmp_path): + c = _client(mods, tmp_path, STRIPE_ENV) + key = _workspace(c) + assert c.get("/v1/credits/thanks").status_code == 200 + assert c.get("/v1/workspace", headers=_auth(key)).json()["credits_remaining"] == 0 + + +def test_interrupted_webhook_processing_grants_exactly_once_on_retry(mods, tmp_path, monkeypatch): + app = mods.create_app(_settings(mods, tmp_path, STRIPE_ENV)) + c = TestClient(app, raise_server_exceptions=False) + key = _workspace(c) + ws = _ws_id(c, key) + real_grant = app.state.ledger.grant + calls = {"n": 0} + + def crash_once(*a, **kw): + calls["n"] += 1 + if calls["n"] == 1: + raise RuntimeError("process died mid-webhook") + return real_grant(*a, **kw) + + monkeypatch.setattr(app.state.ledger, "grant", crash_once) + assert _post_event(c, _session_event(ws)).status_code == 500 # Stripe will retry + assert c.get("/v1/workspace", headers=_auth(key)).json()["credits_remaining"] == 0 + assert _post_event(c, _session_event(ws)).json()["granted"] is True + assert _post_event(c, _session_event(ws)).json()["granted"] is False + assert c.get("/v1/workspace", headers=_auth(key)).json()["credits_remaining"] == 10_000 + + +def test_stripe_refund_event_reverses_credits_idempotently(mods, tmp_path): + c = _client(mods, tmp_path, STRIPE_ENV) + key = _workspace(c) + ws = _ws_id(c, key) + _post_event(c, _session_event(ws)) + assert _post_event(c, _refund_event(1000)).json() == { + "refund_applied": True, + "runs_reversed": 10_000, + } + assert _post_event(c, _refund_event(1000)).json()["runs_reversed"] == 10_000 + statement = c.get("/v1/workspace/ledger", headers=_auth(key)).json() + assert statement["credits_remaining"] == 0 + assert statement["reversals"][0]["runs"] == 10_000 def test_card_rail_is_off_unless_configured(mods, tmp_path): c = _client(mods, tmp_path) key = _workspace(c) - r = c.post("/v1/credits/checkout", headers={"Authorization": f"Bearer {key}"}) - assert r.status_code == 501 + assert c.post("/v1/credits/checkout", headers=_auth(key)).status_code == 501 assert c.post("/v1/stripe/webhook", content=b"{}").status_code == 404 assert "card" not in c.get("/pricing").json()["credits"] -# -- proxy ---------------------------------------------------------------------------------------- +def test_checkout_creates_a_session_with_our_metadata(mods, tmp_path, monkeypatch): + seen = {} + def handler(request): + seen["form"] = dict(httpx.QueryParams(request.content.decode())) + seen["auth"] = request.headers["authorization"] + return httpx.Response(200, json={"url": "https://checkout.stripe.test/s/1"}) -def _mock_openai(calls: list): - def handler(request: httpx.Request) -> httpx.Response: + monkeypatch.setattr( + mods, + "stripe_client_factory", + lambda: httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ) + c = _client(mods, tmp_path, STRIPE_ENV) + key = _workspace(c) + r = c.post("/v1/credits/checkout?packs=3", headers=_auth(key)) + assert r.json()["checkout_url"] == "https://checkout.stripe.test/s/1" + assert seen["form"]["metadata[packs]"] == "3" + assert seen["form"]["metadata[workspace_id]"] == _ws_id(c, key) + assert seen["form"]["line_items[0][price_data][unit_amount]"] == "1000" + assert c.post("/v1/credits/checkout?packs=0", headers=_auth(key)).status_code == 400 + + +# == proxy ======================================================================================== + + +def _mock_openai(calls: list, status=200, delay=0.0, body_extra="", completion_tokens=4): + async def handler(request: httpx.Request) -> httpx.Response: calls.append(request) + if delay: + await asyncio.sleep(delay) + if status != 200: + return httpx.Response( + status, + json={ + "error": { + "message": f"Incorrect API key provided: {PROVIDER_KEY}{body_extra}", + "type": "invalid_request_error", + } + }, + ) body = json.loads(request.content) return httpx.Response( 200, @@ -247,30 +446,35 @@ def handler(request: httpx.Request) -> httpx.Response: "choices": [ {"message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"} ], - "usage": {"prompt_tokens": 12, "completion_tokens": 4}, + "usage": {"prompt_tokens": 12, "completion_tokens": completion_tokens}, }, ) return lambda: httpx.AsyncClient(transport=httpx.MockTransport(handler)) -def _chat(c, key, work="run-1", budget=None, provider_key=PROVIDER_KEY): - headers = {"Authorization": f"Bearer {key}", "X-Inferrail-Attribute-Work-Id": work} +def _headers(key, work="run-1", budget=None, provider_key=PROVIDER_KEY): + h = {"Authorization": f"Bearer {key}", "X-Inferrail-Attribute-Work-Id": work} if provider_key: - headers["X-Provider-Api-Key"] = provider_key + h["X-Provider-Api-Key"] = provider_key if budget: - headers["X-Inferrail-Budget-Usd"] = budget + h["X-Inferrail-Budget-Usd"] = budget + return h + + +CHAT = {"model": "gpt-4o-mini", "max_tokens": 20, "messages": [{"role": "user", "content": "hi"}]} + + +def _chat(c, key, work="run-1", budget=None, provider_key=PROVIDER_KEY, payload=CHAT): return c.post( - "/v1/chat/completions", - headers=headers, - json={ - "model": "gpt-4o-mini", - "max_tokens": 20, - "messages": [{"role": "user", "content": "hi"}], - }, + "/v1/chat/completions", headers=_headers(key, work, budget, provider_key), json=payload ) +def _all_bytes(directory: Path) -> bytes: + return b"".join(p.read_bytes() for p in directory.iterdir() if p.is_file()) + + def test_provider_key_is_forwarded_but_never_stored_or_returned(mods, tmp_path, monkeypatch): calls: list = [] monkeypatch.setattr(mods, "openai_client_factory", _mock_openai(calls)) @@ -280,10 +484,22 @@ def test_provider_key_is_forwarded_but_never_stored_or_returned(mods, tmp_path, assert r.status_code == 200, r.text assert calls[0].headers["authorization"] == f"Bearer {PROVIDER_KEY}" assert PROVIDER_KEY not in r.text - blob = b"".join(p.read_bytes() for p in (tmp_path / "data").iterdir() if p.is_file()) - assert PROVIDER_KEY.encode() not in blob - work = c.get("/v1/work/run-1", headers={"Authorization": f"Bearer {key}"}).json() - assert work["calls"] == 1 + assert PROVIDER_KEY.encode() not in _all_bytes(tmp_path / "data") + work = c.get("/v1/work/run-1", headers=_auth(key)).json() + assert work["calls"] == 1 and PROVIDER_KEY not in json.dumps(work) + + +def test_provider_errors_never_echo_the_key_and_are_not_billed(mods, tmp_path, monkeypatch): + calls: list = [] + monkeypatch.setattr(mods, "openai_client_factory", _mock_openai(calls, status=401)) + c = _client(mods, tmp_path) + key = _workspace(c) + r = _chat(c, key) + assert r.status_code >= 400 + assert PROVIDER_KEY not in r.text + assert PROVIDER_KEY.encode() not in _all_bytes(tmp_path / "data") + u = c.get("/v1/workspace", headers=_auth(key)).json() + assert u["governed_runs"] == 0 and u["billed_runs"] == 0 def test_missing_provider_key_is_refused_without_metering(mods, tmp_path, monkeypatch): @@ -292,16 +508,14 @@ def test_missing_provider_key_is_refused_without_metering(mods, tmp_path, monkey c = _client(mods, tmp_path) key = _workspace(c) assert _chat(c, key, provider_key=None).status_code == 400 - assert ( - c.get("/v1/workspace", headers={"Authorization": f"Bearer {key}"}).json()["governed_runs"] - == 0 - ) + assert c.get("/v1/workspace", headers=_auth(key)).json()["governed_runs"] == 0 assert calls == [] def test_unknown_workspace_key_is_rejected(mods, tmp_path): c = _client(mods, tmp_path) assert _chat(c, "irw_nope").status_code == 401 + assert c.get("/v1/workspace").status_code == 401 def test_exhausted_allowance_gets_402_with_purchase_options_before_the_provider( @@ -321,7 +535,7 @@ def test_exhausted_allowance_gets_402_with_purchase_options_before_the_provider( assert len(calls) == 3 -def test_declared_run_budget_is_enforced_before_the_provider(mods, tmp_path, monkeypatch): +def test_a_run_refused_by_its_own_budget_is_not_billed(mods, tmp_path, monkeypatch): calls: list = [] monkeypatch.setattr(mods, "openai_client_factory", _mock_openai(calls)) c = _client(mods, tmp_path) @@ -331,19 +545,116 @@ def test_declared_run_budget_is_enforced_before_the_provider(mods, tmp_path, mon assert r.json()["error"]["type"] == "BudgetExceededError" assert calls == [] assert _chat(c, key, "bad", budget="-1").status_code == 400 + u = c.get("/v1/workspace", headers=_auth(key)).json() + assert u["governed_runs"] == 0 and u["free_runs_remaining"] == 2 + + +def test_concurrent_calls_in_one_run_cannot_overspend_its_budget(mods, tmp_path, monkeypatch): + """Many clients, one run, one budget: the reservation admits only what fits.""" + calls: list = [] + monkeypatch.setattr(mods, "openai_client_factory", _mock_openai(calls, delay=0.2)) + app = mods.create_app(_settings(mods, tmp_path)) + key = TestClient(app).post("/v1/workspaces").json()["api_key"] + payload = {**CHAT, "max_tokens": 1000} + + async def burst(): + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://t") as client: + return await asyncio.gather( + *[ + client.post( + "/v1/chat/completions", + json=payload, + headers=_headers(key, "shared-run", budget="0.002"), + ) + for _ in range(12) + ] + ) + + responses = asyncio.run(burst()) + ok = [r for r in responses if r.status_code == 200] + refused = [r for r in responses if r.status_code == 402] + assert 1 <= len(ok) == len(calls) <= 3 # gpt-4o-mini, 1,000 max tokens ≈ $0.0006 each + assert len(ok) + len(refused) == 12 + usage = TestClient(app).get("/v1/workspace", headers=_auth(key)).json() + assert usage["billed_runs"] == 1 # one run, however many calls + + +def test_budget_state_survives_a_restart(mods, tmp_path, monkeypatch): + calls: list = [] + # 1,000 completion tokens of gpt-4o-mini ~= $0.0006: a second such call can't fit in $0.0011. + monkeypatch.setattr(mods, "openai_client_factory", _mock_openai(calls, completion_tokens=1000)) + settings = _settings(mods, tmp_path) + app = mods.create_app(settings) + c = TestClient(app) + key = _workspace(c) + payload = {**CHAT, "max_tokens": 1000} + first = c.post("/v1/chat/completions", json=payload, headers=_headers(key, "r", "0.0011")) + assert first.status_code == 200 + app.state.release_instance_lock() # the process exits + c2 = TestClient(mods.create_app(settings)) + again = c2.post("/v1/chat/completions", json=payload, headers=_headers(key, "r", "0.0011")) + assert again.status_code == 402 # the run's spend was kept across the restart + assert len(calls) == 1 + assert c2.get("/v1/workspace", headers=_auth(key)).json()["billed_runs"] == 1 + + +def test_a_second_instance_on_the_same_data_directory_refuses_to_start(mods, tmp_path): + settings = _settings(mods, tmp_path) + mods.create_app(settings) + with pytest.raises(mods.InstanceLockError): + mods.create_app(settings) -# -- x402 credit rail ----------------------------------------------------------------------------- +def test_workspaces_are_isolated(mods, tmp_path, monkeypatch): + calls: list = [] + monkeypatch.setattr(mods, "openai_client_factory", _mock_openai(calls)) + c = _client(mods, tmp_path) + a, b = _workspace(c), _workspace(c) + payload = {**CHAT, "max_tokens": 1000} + r_a = c.post("/v1/chat/completions", json=payload, headers=_headers(a, "same-id", "0.0011")) + assert r_a.status_code == 200 + # Same run id in another workspace has its own, untouched budget. + r_b = c.post("/v1/chat/completions", json=payload, headers=_headers(b, "same-id", "0.0011")) + assert r_b.status_code == 200 + assert c.get("/v1/work/same-id", headers=_auth(a)).json()["calls"] == 1 + assert c.get("/v1/work/same-id", headers=_auth(b)).json()["calls"] == 1 + c.post("/v1/stripe/webhook") # unconfigured: 404, nothing granted anywhere + assert c.get("/v1/workspace", headers=_auth(b)).json()["governed_runs"] == 1 + + +def test_streaming_calls_are_billed_once_the_provider_answers(mods, tmp_path, monkeypatch): + raw = ( + b'data: {"choices":[{"delta":{"content":"Hi"}}]}\n\n' + b'data: {"choices":[{"delta":{}}],"usage":{"prompt_tokens":9,"completion_tokens":2}}' + b"\n\ndata: [DONE]\n\n" + ) + monkeypatch.setattr( + mods, + "openai_client_factory", + lambda: httpx.AsyncClient( + transport=httpx.MockTransport( + lambda r: httpx.Response( + 200, content=raw, headers={"content-type": "text/event-stream"} + ) + ) + ), + ) + c = _client(mods, tmp_path) + key = _workspace(c) + r = _chat(c, key, "s", payload={**CHAT, "stream": True}) + assert r.status_code == 200 and "[DONE]" in r.text + assert c.get("/v1/workspace", headers=_auth(key)).json()["billed_runs"] == 1 + +# == x402 credit rail ============================================================================= -def _buy(c, key, facilitator_ok=True): - unpaid = c.post("/v1/credits/x402", headers={"Authorization": f"Bearer {key}"}) + +def _buy(c, key): + unpaid = c.post("/v1/credits/x402", headers=_auth(key)) assert unpaid.status_code == 402 signed = sign_payment(decode_payment_required(unpaid)) - return c.post( - "/v1/credits/x402", - headers={"Authorization": f"Bearer {key}", "PAYMENT-SIGNATURE": signed.header}, - ) + return c.post("/v1/credits/x402", headers={**_auth(key), "PAYMENT-SIGNATURE": signed.header}) def test_x402_purchase_grants_credits_after_settlement(mods, tmp_path): @@ -353,8 +664,9 @@ def test_x402_purchase_grants_credits_after_settlement(mods, tmp_path): r = _buy(c, key) assert r.status_code == 200, r.text assert [x.method for x in fac.calls] == ["verify", "settle"] - usage = c.get("/v1/workspace", headers={"Authorization": f"Bearer {key}"}).json() - assert usage["credits_remaining"] == 1000 + assert c.get("/v1/workspace", headers=_auth(key)).json()["credits_remaining"] == 1000 + statement = c.get("/v1/workspace/ledger", headers=_auth(key)).json() + assert statement["grants"][0]["source"] == "x402" and statement["pending_purchases"] == [] def test_x402_settlement_failure_grants_nothing(mods, tmp_path): @@ -362,8 +674,30 @@ def test_x402_settlement_failure_grants_nothing(mods, tmp_path): c = _client(mods, tmp_path, {"BG_X402_PAY_TO": PAY_TO}, facilitator=fac) key = _workspace(c) assert _buy(c, key).status_code == 402 - usage = c.get("/v1/workspace", headers={"Authorization": f"Bearer {key}"}).json() - assert usage["credits_remaining"] == 0 + assert c.get("/v1/workspace", headers=_auth(key)).json()["credits_remaining"] == 0 + + +def test_a_settled_x402_payment_is_not_lost_when_the_grant_crashes(mods, tmp_path, monkeypatch): + """Settlement succeeded, then the process failed before granting. The pending row survives, + and reconciliation credits it once the chain shows the authorization was used.""" + app = mods.create_app( + _settings(mods, tmp_path, {"BG_X402_PAY_TO": PAY_TO}), facilitator=FakeFacilitator() + ) + c = TestClient(app, raise_server_exceptions=False) + key = _workspace(c) + + def crash(*_a, **_kw): + raise RuntimeError("process died after settlement") + + monkeypatch.setattr(app.state.ledger, "settle_pending", crash) + _buy(c, key) + assert c.get("/v1/workspace", headers=_auth(key)).json()["credits_remaining"] == 0 + monkeypatch.undo() + ledger = app.state.ledger + assert len(ledger.pending()) == 1 + assert ledger.reconcile_pending(lambda _a, _n: True)["settled"] == 1 + assert ledger.reconcile_pending(lambda _a, _n: True)["settled"] == 0 + assert c.get("/v1/workspace", headers=_auth(key)).json()["credits_remaining"] == 1000 def test_x402_payment_with_an_invalid_workspace_is_never_settled(mods, tmp_path): @@ -379,28 +713,55 @@ def test_x402_payment_with_an_invalid_workspace_is_never_settled(mods, tmp_path) assert fac.calls_to("settle") == [] -def test_mainnet_credit_sales_need_explicit_approval(mods, tmp_path): - with pytest.raises(RuntimeError): - mods.Settings.from_env({"BG_X402_NETWORK": "eip155:8453", "BG_X402_PAY_TO": PAY_TO}) +def test_chain_check_reads_usdc_authorization_state(mods): + cc = sys.modules["chain_check"] + seen = {} + + def handler(request): + seen["body"] = json.loads(request.content) + return httpx.Response(200, json={"result": "0x" + "0" * 63 + "1"}) + + client = httpx.Client(transport=httpx.MockTransport(handler)) + assert ( + cc.authorization_used( + "http://rpc", "eip155:8453", "0x" + "a" * 40, "0x" + "b" * 64, client=client + ) + is True + ) + call = seen["body"]["params"][0] + assert call["to"] == cc.USDC["eip155:8453"] and call["data"].startswith("0xe94a0102") + down = httpx.Client(transport=httpx.MockTransport(lambda r: httpx.Response(500, text="x"))) assert ( - mods.Settings.from_env( - { - "BG_X402_NETWORK": "eip155:8453", - "BG_X402_PAY_TO": PAY_TO, - "BG_X402_MAINNET_APPROVED": "1", - } - ).x402_pay_to - == PAY_TO + cc.authorization_used("http://rpc", "eip155:8453", "0x" + "a" * 40, "0x1", client=down) + is None ) +def test_mainnet_credit_sales_need_explicit_approval(mods): + with pytest.raises(RuntimeError): + mods.Settings.from_env({"BG_X402_NETWORK": "eip155:8453", "BG_X402_PAY_TO": PAY_TO}) + env = { + "BG_X402_NETWORK": "eip155:8453", + "BG_X402_PAY_TO": PAY_TO, + "BG_X402_MAINNET_APPROVED": "1", + } + with pytest.raises(RuntimeError, match="BG_BASE_RPC_URL"): + mods.Settings.from_env(env) # no reconciliation source: refuse + approved = mods.Settings.from_env({**env, "BG_BASE_RPC_URL": "https://rpc.example"}) + assert approved.x402_pay_to == PAY_TO + + +# == published terms and guards =================================================================== + + def test_pricing_and_llms_txt_publish_the_same_terms(mods, tmp_path): c = _client(mods, tmp_path, {"BG_X402_PAY_TO": PAY_TO}, facilitator=FakeFacilitator()) p = c.get("/pricing").json() assert p["free_governed_runs_per_month"] == 2 and p["subscription"] is None + assert p["status"] == "experimental pricing" and "answered" in p["billable_unit"] assert p["credits"]["x402"]["price_usd"] == "1.00" txt = c.get("/llms.txt").text - assert "X-Provider-Api-Key" in txt and "/pricing" in txt + assert "X-Provider-Api-Key" in txt and "/pricing" in txt and "answered" in txt def test_workspace_creation_is_throttled_per_address(mods, tmp_path): @@ -408,3 +769,41 @@ def test_workspace_creation_is_throttled_per_address(mods, tmp_path): assert c.post("/v1/workspaces").status_code == 201 assert c.post("/v1/workspaces").status_code == 201 assert c.post("/v1/workspaces").status_code == 429 + + +def test_oversized_bodies_are_refused(mods, tmp_path): + c = _client(mods, tmp_path, BG_MAX_BODY_BYTES="100") + key = _workspace(c) + big = {**CHAT, "messages": [{"role": "user", "content": "x" * 500}]} + assert _chat(c, key, payload=big).status_code == 413 + + +# == the customer journey ========================================================================= + + +def test_complete_customer_journey(mods, tmp_path, monkeypatch): + """Discover → workspace → governed work with a budget → enforcement and receipts → allowance + runs out → buy credits (agent, x402) → continue.""" + calls: list = [] + monkeypatch.setattr(mods, "openai_client_factory", _mock_openai(calls)) + c = _client(mods, tmp_path, {"BG_X402_PAY_TO": PAY_TO}, facilitator=FakeFacilitator()) + + terms = c.get("/pricing").json() # 1. discover and understand the offer + assert terms["free_governed_runs_per_month"] == 2 + key = c.post("/v1/workspaces").json()["api_key"] # 2. workspace, no account + + assert _chat(c, key, "job-1", budget="0.50").status_code == 200 # 3. governed work + blocked = _chat(c, key, "job-2", budget="0.000000001") # 4. enforcement + assert blocked.status_code == 402 and blocked.json()["error"]["type"] == "BudgetExceededError" + receipts = c.get("/v1/work/job-1", headers=_auth(key)).json() + assert receipts["calls"] == 1 and receipts["receipts"][0]["status"] == "success" + + assert _chat(c, key, "job-3").status_code == 200 # free allowance now used (2 billed) + out = _chat(c, key, "job-4") # 5. payment is required, and says how + assert out.status_code == 402 and out.json()["detail"]["error"] == "allowance_exhausted" + assert len(calls) == 2 # nothing reached the provider on refusal + + assert _buy(c, key).status_code == 200 # 6. buy credits + assert _chat(c, key, "job-4").status_code == 200 # 7. continue + status = c.get("/v1/workspace", headers=_auth(key)).json() + assert status["billed_runs"] == 3 and status["credits_remaining"] == 999