Skip to content

feat: meter research runs against account credits (route-0 billing) - #531

Merged
MrParamecium merged 3 commits into
mainfrom
feat/research-credits
Sep 25, 2026
Merged

MrParamecium merged 3 commits into
mainfrom
feat/research-credits

Conversation

@MrParamecium

@MrParamecium MrParamecium commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

What this does

User-facing billing for research runs on the real-money Credits rail ($1 = 1 credit) — the same system Stripe purchases fund and platform-credit backtests settle on (CreditsStore, micro-denominated). Reworked from the first draft per confirmation: the earlier route-0 draft debited the legacy integer admin quota, which real-money credit holders never carried.

Design

  • Submit: credits_service.reserve_llm_credits holds a per-run usage ceiling (RESEARCH_RUN_ESTIMATE_USD_CENTS, default $1) from the caller's balance. Store's InsufficientCreditsError → 402.
  • Agent service runs the research (background; minutes).
  • Completion (discovered by poll): settle at the reserved estimate — route-0 interim; contract v1.1's usage reporting upgrades this to settle-at-actual without a call-site change.
  • Discovered failure: release the reservation.
  • Failure before acceptance never debits; failure after acceptance does not refund (provider money already spent).

Changes

  • domain/entitlements/research_credits.py — authorize/refund one research run against the account balance. Same strict opt-in master switch as backtest metering (CREDITS_METERING_ENABLED); the per-run price lives in RESEARCH_RUN_CREDIT_COST (env, default 10) because Deep Research spend varies by agent and provider tier in a way backtest granularity never did. Store errors fail open like authorize_llm_run.
  • api/routers/research.py — create_research_run debits at accept, passes through the service's 422s, refunds on submission failure when charged.
  • Route-0 interim semantics documented in the module: today the provider key belongs to the agent author; when the LLM gateway lands, the debit's meaning shifts without a call-site change.
  • domain/entitlements/research_credits.py deleted — superseded by this CreditsStore wiring. Legacy credits.py annotated NOT-wired (flat per-run debit; the live backtest billing is the reserve–settle system).
  • Contract v1.1 (separate): result payload gains usage reporting so settlement moves from reserved-estimate to actual spend.

User-facing billing for research runs, per the teacher-approved direction
(platform credits settle the author; LLM gateway comes later):

- domain/entitlements/research_credits.py: authorize/refund one research
  run against the account balance. Same strict-opt-in master switch as
  backtest metering (CREDITS_METERING_ENABLED); the per-run price lives in
  RESEARCH_RUN_CREDIT_COST (env, default 10) because Deep Research spend
  varies by agent and provider tier in a way backtest granularity never
  did. Store errors fail open like authorize_llm_run.
- POST /api/v1/research/agents/{id}/runs: debit at accept, before the
  agent service is called; 402 with an account-shaped message when the
  balance can't cover it; refund only when the service never accepted the
  run — refund_credits is unconditional at the store level, so the route
  gates on outcome.charged to never mint free credits.
- test_research_credits.py: the billing decision table (off/armed/empty/
  anonymous/cost-env/refund pairing), on the same temp-store seams as
  test_credit_metering.
@vercel

vercel Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
agentic-trading-lab Ready Ready Preview Sep 25, 2026 7:23am UTC

Comment thread dashboard/backend/tests/test_research_credits.py Fixed
Comment thread dashboard/backend/tests/test_research_credits.py Fixed
…edit)

Reworks route-0 billing per confirmation: research runs now reserve and
settle on the CreditsStore ($1 = 1 credit, micro-denominated) — the same
real-money rail platform-credit backtests settle on — instead of the
legacy integer admin quota, which real-money credit holders never
carried.

- submit: credits_service.reserve_llm_credits holds a per-run usage
  ceiling (RESEARCH_RUN_ESTIMATE_USD_CENTS, default $1); the store's
  InsufficientCreditsError maps to 402. The minted reservation id is
  persisted on the run row (new column, ALTER-migrated for existing
  installs).
- completion: settle at the reserved estimate (route-0 interim);
  contract v1.1's usage reporting upgrades this to settle-at-actual.
- discovered failure: release the reservation.
- the route-0 integer-quota module (research_credits.py) and its tests
  are deleted; the legacy entitlements flat module (credits.py) is
  annotated NOT-wired so nobody wires it by mistake.
@MrParamecium

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 19fb28b81b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +283 to +285
credits_service.release_llm_credits(
run["reservation_id"], reason="research run failed",
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep result-fetch failures from stranding reservations

The reservation is released when the status response says failed, but not when a completed run's /result request raises an HTTP error: that branch marks the run failed, after which _maybe_complete_run returns early on every poll. Because an open reservation is deducted from available Credits, even a transient result-endpoint 5xx permanently strands the user's held balance; keep that failure retryable or otherwise close the reservation before making the run terminal.

Useful? React with 👍 / 👎.

Comment on lines +251 to +253
def _estimate_micro_from_run(run: Dict[str, Any]) -> int:
"""Route-0 settle amount: the reserved estimate, from env-tunable cents."""
return credits_micro_for_cents(_estimate_usd_cents())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Settle the amount reserved at submission

This recomputes the settlement from the current environment instead of using the amount held for this run. If RESEARCH_RUN_ESTIMATE_USD_CENTS changes during a deployment while a run is in progress, a run reserved at 1 Credit can later settle at 2 Credits, consuming supplementary balance or recording an overage and restricting the account; persist the submitted estimate or retrieve the reservation's reserved_micro for route-0 settlement.

Useful? React with 👍 / 👎.

try:
with _connect() as conn:
conn.execute(
"ALTER TABLE research_runs ADD COLUMN reservation_id TEXT"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Exempt migrated runs that have no reservation

On an existing installation this migration leaves reservation_id NULL for every pre-billing row. If a queued or running legacy run completes after deployment, _maybe_complete_run passes that NULL value to settle_llm_credits (or to release_llm_credits on failure), which raises because reservation IDs must be non-empty; completed legacy reports therefore remain stuck as running and cannot be opened. Legacy rows need an explicit no-billing completion path or a valid backfill.

Useful? React with 👍 / 👎.

Comment on lines +231 to +234
except httpx.HTTPError as exc:
credits_service.release_llm_credits(
reservation_id, reason="submit failed; no Deep Research invocation"
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Close holds on malformed submit responses

This cleanup only runs for httpx.HTTPError, while response.json() raises JSONDecodeError/ValueError for a 2xx response with malformed JSON. In that scenario the newly created reservation remains open, no local run is persisted to settle or release it later, and each client retry can strand another portion of the user's balance; the post-reservation submission path must close the hold for response-decoding and shape failures too.

Useful? React with 👍 / 👎.

Comment on lines +218 to +219
except InsufficientCreditsError as exc:
raise HTTPException(status_code=402, detail=str(exc)) from None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Map restricted credit accounts to a refusal response

The credit store also raises CreditAccountRestrictedStoreError when an account is paused for refund review or unpaid overage, but this handler catches only insufficient balance. Any restricted user submitting research therefore receives an uncaught 500 instead of the intentional account-restricted refusal used by the other Credits execution path; catch and translate that store error to an appropriate 403/402 response.

Useful? React with 👍 / 👎.

- Settle at the amount reserved for THIS run (persisted estimate_micro at
  submit) instead of re-reading the env at completion — a mid-flight env
  change could settle a different price than was reserved.
- Result-fetch failures are retryable, not terminal: the run completed on
  the service, so a transient /result 5xx must not strand the reservation
  behind a failed terminal status.
- Release the hold on any post-reserve submit failure, including a 2xx
  response whose JSON body is malformed (ValueError path), which
  previously left the hold open with no run row to settle it later.
- Map CreditAccountRestrictedStoreError to a 403 refusal instead of an
  uncaught 500, matching the other Credits surfaces.
- Legacy pre-billing runs (reservation_id NULL after the migration)
  complete without touching Credits instead of raising on a NULL id.
@MrParamecium
MrParamecium merged commit 7ca87ca into main Sep 25, 2026
8 checks passed

This branch was successfully deployed

1 active deployment
Preview — 6b510cb4 Deployed Sep 25, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants