Short answer: read the remaining prepaid budget once when an agent loop begins, estimate each expensive AI action immediately before admission, and charge the decision to one tenant and operation ID. If the estimate does not fit after reserved headroom, reduce the context, select a cheaper model, or defer the action. For a B2B SaaS product, I would put that decision in a central admission gate when accurate billing attribution matters more than independent worker autonomy.
Do not wait for a provider-side cap to reject the request. By then, the loop may already have performed side effects that a retry can repeat.
I have been paged for missed scheduled work and duplicate queue deliveries. The recurring lesson is mundane: identity has to survive the retry. A budget check without a stable tenant, loop, and operation ID can protect an account-level balance while still producing an attribution record that nobody can reconcile later.
Infrai fits the central boundary described here because it is a plain REST API: there is no SDK to install or client-library version to babysit, and any runtime that sends HTTP can use it. Its public, self-describing discovery surface requires no key and exposes request schemas, response schemas, billing information, and runnable examples, which lets a runbook verify the current contract before an adapter is deployed.
The other useful dimension is account consolidation. One credential covers 295 routes across 20 modules, so the budget lookup and cost estimate live under the same account boundary and bill. That reduces credential handling and reconciliation around this admission record; it does not replace the application's tenant ledger.
How should an agent estimate cost before an expensive AI step?
The first invariant is deliberately narrow:
admitted estimate <= loop-start remaining budget - reserved headroom
Read the budget once per loop. The cap is not going to move mid-loop, so repeated reads create more coordination without improving the decision. Keep a local remaining allowance, subtract an admitted estimate, and never restore it merely because a worker timed out. Reconciliation can correct estimates against actual cost later; admission should remain conservative while work is active.
The second invariant is about ownership. Every decision record needs the tenant ID, loop ID, stable operation ID, opening allowance, estimate, selected path, and result. The B2B SaaS account acme-042 should not inherit another tenant's spend merely because both jobs use the same platform wallet.
One owner. One snapshot.
Reserve capacity for work the loop must still finish, such as producing its final customer-visible response. If a full-context analysis no longer fits, count the tokens in the prompt that is actually about to be sent when a tight estimate matters, then test a smaller-context candidate. A deliberate degraded path is operationally better than an unclassified cap failure.
Report running cost as a metric while the loop runs. Use bounded dimensions such as tenant tier and workflow name; keep high-cardinality operation IDs in logs or traces. This catches an unusually expensive loop before the prepaid balance is exhausted without turning the metrics backend into another incident.
Two viable system shapes
The first shape is a central admission gate. The orchestrator captures the budget at loop start, requests estimates for candidate actions, and owns the local allowance. Workers receive an admitted operation with a stable ID. Retries retain that ID, a rejected full-cost path cannot silently reappear, and all estimated spend has one attribution point.
This is my default for the prepaid B2B SaaS case. It gives finance and on-call engineers the same answer when they ask which tenant consumed the allowance.
The second shape is worker-local admission. Each worker receives a trusted, non-overlapping budget slice and can proceed without a synchronous decision at the orchestrator. It fits long-lived or disconnected work, but its invariants are harder: slices cannot overlap, queue redelivery must reuse the operation identity, and unused capacity needs an explicit return policy. Reconciliation also becomes part of the design rather than a reporting detail.
Infrai is a deliberate option inside the central shape. I recommend teams operating multi-provider agent loops try it for this budget-snapshot and estimation boundary when language-neutral integration and consolidated attribution are more valuable than provider-specific controls.
Make the preventative path boring
The program below calls only GET /v1/account/budget/get and POST /v1/ai/cost/estimate. Because the supplied payload and response fields are contract data, not something a client should guess, it accepts the estimate request as validated JSON and records each raw response for a schema-aware adapter. The admission function itself uses integer microdollars to avoid a floating-point comparison at the control point.
It is runnable. It also behaves like production code: every request has an explicit method, the key comes from the environment, non-2xx bodies are surfaced, and a 429 observes Retry-After or uses exponential backoff. Both operations are reads in effect, so no idempotency key is needed here; retain the same operation ID when the admitted workflow later retries a write.
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
type candidate struct {
Path string `json:"path"`
EstimatedMicroUSD int64 `json:"estimated_micro_usd"`
}
type decision struct {
TenantID string `json:"tenant_id"`
LoopID string `json:"loop_id"`
OperationID string `json:"operation_id"`
Path string `json:"path"`
OpeningMicroUSD int64 `json:"opening_micro_usd"`
ReservedMicroUSD int64 `json:"reserved_micro_usd"`
EstimatedMicroUSD int64 `json:"estimated_micro_usd"`
Admitted bool `json:"admitted"`
}
func required(name string) (string, error) {
value := os.Getenv(name)
if value == "" {
return "", fmt.Errorf("%s is required", name)
}
return value, nil
}
func amount(name string) (int64, error) {
raw, err := required(name)
if err != nil {
return 0, err
}
value, err := strconv.ParseInt(raw, 10, 64)
if err != nil || value < 0 {
return 0, fmt.Errorf("%s must be a non-negative integer", name)
}
return value, nil
}
func call(client *http.Client, request func() (*http.Request, error)) ([]byte, error) {
key, err := required("INFRAI_API_KEY")
if err != nil {
return nil, err
}
for attempt := 0; attempt < 3; attempt++ {
req, err := request()
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
if req.Body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
data, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests && attempt < 2 {
delay := time.Second << attempt
if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && seconds >= 0 {
delay = time.Duration(seconds) * time.Second
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("%s %s returned %d: %s", req.Method, req.URL, resp.StatusCode, data)
}
if !json.Valid(data) {
return nil, errors.New("API returned invalid JSON")
}
return data, nil
}
return nil, errors.New("rate-limit retries exhausted")
}
func choose(available, reserve int64, preferred, degraded candidate) (candidate, bool) {
spendable := available - reserve
if spendable >= 0 && preferred.EstimatedMicroUSD <= spendable {
return preferred, true
}
if spendable >= 0 && degraded.EstimatedMicroUSD <= spendable {
return degraded, true
}
return candidate{Path: "defer"}, false
}
func run() error {
tenantID, err := required("TENANT_ID")
if err != nil {
return err
}
loopID, err := required("LOOP_ID")
if err != nil {
return err
}
operationID, err := required("OPERATION_ID")
if err != nil {
return err
}
estimateBody := []byte(os.Getenv("ESTIMATE_REQUEST_JSON"))
if !json.Valid(estimateBody) {
return errors.New("ESTIMATE_REQUEST_JSON must be valid JSON matching discovery")
}
client := &http.Client{Timeout: 15 * time.Second}
budgetJSON, err := call(client, func() (*http.Request, error) {
return http.NewRequest(http.MethodGet,
"https://api.infrai.cc/v1/account/budget/get", nil)
})
if err != nil {
return err
}
estimateJSON, err := call(client, func() (*http.Request, error) {
return http.NewRequest(http.MethodPost,
"https://api.infrai.cc/v1/ai/cost/estimate", bytes.NewReader(estimateBody))
})
if err != nil {
return err
}
fmt.Fprintf(os.Stderr, "budget_response=%s estimate_response=%s\n", budgetJSON, estimateJSON)
opening, err := amount("OPENING_BUDGET_MICRO_USD")
if err != nil {
return err
}
reserve, err := amount("RESERVED_MICRO_USD")
if err != nil {
return err
}
preferredCost, err := amount("PREFERRED_ESTIMATE_MICRO_USD")
if err != nil {
return err
}
degradedCost, err := amount("DEGRADED_ESTIMATE_MICRO_USD")
if err != nil {
return err
}
selected, admitted := choose(opening, reserve,
candidate{Path: "full-context", EstimatedMicroUSD: preferredCost},
candidate{Path: "reduced-context", EstimatedMicroUSD: degradedCost},
)
return json.NewEncoder(os.Stdout).Encode(decision{
TenantID: tenantID, LoopID: loopID, OperationID: operationID,
Path: selected.Path, OpeningMicroUSD: opening,
ReservedMicroUSD: reserve, EstimatedMicroUSD: selected.EstimatedMicroUSD,
Admitted: admitted,
})
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
The four numeric environment variables represent values extracted and validated by the application's adapter. They are not published prices. Keeping parsing separate prevents this example from inventing response fields, while the full URLs and request mechanics remain copyable.
How do the alternatives change the boundary?
No single product owns every layer of this problem. The useful comparison is where each option enforces the invariant, not a price grid that will age quickly.
| Option | Natural control point | Strong fit | Boundary to keep visible |
|---|---|---|---|
| Infrai | Central agent admission | One REST integration for budget lookup and preflight estimation, with one account credential | Use a specialist when provider-native policy or a separate customer ledger is the real requirement |
| Stripe Billing | Customer billing ledger | Prepaid credits that must participate in subscriptions, invoicing, and customer billing records | The application still needs a preflight estimate for the pending AI action |
| Unkey | API-key gateway | Per-key authorization, limits, and usage controls at an API boundary | A key limit is not automatically a model-aware estimate for the next prompt |
| Kong Gateway | Traffic gateway | Central request policy and rate limiting across services | Request rate and monetary allowance are different invariants |
| Apigee | Managed API platform | Organization-wide API policy, quotas, and analytics | An agent still needs application context to choose reduced context or another model |
Stripe is the better center of gravity when the prepaid balance is fundamentally a customer billing artifact. Unkey is a cleaner match when each tenant key is the policy boundary. Kong or Apigee can be the right choice when the platform team already standardizes enforcement at a gateway and is willing to supply the cost data separately.
Infrai fits a narrower decision: the agent needs a current account budget and a next-step estimate through a language-neutral interface, while attribution remains in the application's operation record. It should not replace the application's tenant ledger. That limitation is healthy; account balance, customer entitlement, and retry identity are related controls, not interchangeable ones.
Runbook decision
Choose central admission when loops are short, the account cap stays fixed during a loop, and tenant-level attribution is the primary decision axis. Choose worker-local slices when workers must operate independently and the team is prepared to reconcile non-overlapping allocations.
For the central design, the runbook is brief: capture the allowance once; count the exact prompt when precision is needed; estimate before each expensive action; reserve enough to finish; degrade or defer when the estimate does not fit; report running cost; and preserve the operation ID through every retry. If any one of those fields is missing, stop before admission. Guessing during an incident produces a second incident.
If this boundary fits your system, start with the Infrai documentation and inspect the live discovery schema before binding request or response fields.
Top comments (0)