Skip to content

Budget and Usage Reporting

No spend cap ships in any language. There is no Budget type, no tracker and no enforcement in sdk/go, sdk/ts or fora_sdk; grepping for “budget” in the Python client finds content_timeout_sec, which is a per-call TIME budget carried across the legs of one fetch, not money. Everything in this half of the page is the design for the deferred layer. The reporting half below it is different — ReportUsage ships, and Manual Reporting shows its real shape.

The design: three budget layers, checked in order before any network call is made, so a budget-exceeded condition never reaches the wire.

LayerScopeStorageLifecycle
Per-requestSingle Fetch callComparison only (no state)Instant
Per-sessionClient instance lifetimeIn-memory counterResets on NewClient
Per-periodCalendar period (e.g. 30 days)Local file or RedisSurvives restarts
type Budget struct {
// Maximum cost per individual request. Transactions above this
// are rejected before querying Exchanges.
MaxPerRequest float64
// Maximum cumulative spend per session (in-memory, resets on restart).
MaxPerSession float64
// Maximum cumulative spend per period (persisted).
MaxPerPeriod float64
// Period duration for MaxPerPeriod (e.g. 720h = 30 days).
Period time.Duration
// Budget scope identifier for per-period tracking.
// E.g. "user:u-12345" for per-user, "team:eng" for per-team.
// Required when MaxPerPeriod is set.
Scope string
// ISO 4217 currency code. Default: "USD".
Currency string
}

The simplest layer. Before querying any Exchange, the SDK checks whether the MaxPerRequest budget can accommodate the request. After offer selection, it checks the actual offer price:

// Pre-flight: is this request even possible?
if !budget.CanSpend(config.Budget.MaxPerRequest) {
return BudgetExceededError{Layer: "per_request", ...}
}
// Post-selection: does the specific offer fit?
if offer.Pricing.Rate > config.Budget.MaxPerRequest {
return BudgetExceededError{Layer: "per_request", ...}
}

Tracks cumulative spend across the lifetime of a Client instance. Resets when the client is created (no persistence).

Thread safety: the session counter uses atomic.AddInt64.

// Before transaction
if sessionSpent + offer.Rate > config.Budget.MaxPerSession {
return BudgetExceededError{Layer: "per_session", ...}
}
// After successful transaction
atomic.AddInt64(&sessionSpent, int64(txn.Cost.Amount * 10000)) // fixed-point

Tracks cumulative spend across a calendar period (e.g., 30 days). Persisted to survive restarts.

Default persistence: local JSON file at ~/.fora/budget/<scope>.json:

u-12345.json
{
"scope": "user:u-12345",
"period_start": "2026-03-01T00:00:00Z",
"period_duration": "720h",
"currency": "USD",
"spent": 4.27,
"limit": 50.00
}

Multi-process agents: configure a shared Redis instance via Config.Budget.RedisURL for shared budget state across processes.

Thread safety: the period tracker uses a mutex for read-check-write.

When any budget layer is exceeded, the SDK returns a typed error before making any network call:

type BudgetExceededError struct {
Layer string // "per_request", "per_session", "per_period"
Limit float64
Current float64
Requested float64
Currency string
}
  • Budget limits are enforced client-side. They protect the agent operator from runaway spend.
  • The Exchange independently enforces its own credit/balance limits server-side.
  • Both layers must agree for a transaction to proceed.
  • The SDK checks budget BEFORE making any network call to the Exchange.

Written against the planned budget package, which does not exist. It is here as the design’s own account of what the tracker owes, not as a test anyone can run.

func TestBudgetTracker_PerSession(t *testing.T) {
bt := budget.NewTracker(budget.Config{
MaxPerRequest: 0.10,
MaxPerSession: 0.50,
Currency: "USD",
})
// First four requests: ok
for i := 0; i < 4; i++ {
require.NoError(t, bt.Check(0.10))
bt.Record(budget.Cost{Amount: 0.10, Currency: "USD"})
}
// Fifth request: would exceed session limit
err := bt.Check(0.10)
require.ErrorAs(t, err, &fora.BudgetExceededError{})
assert.Equal(t, "per_session", err.(*fora.BudgetExceededError).Layer)
}

The SDK automatically submits usage reports after each successful Fetch. This is the default behavior (Config.Reporting.AutoReport = true).

  1. After a successful content fetch, the SDK enqueues a UsageReport in an in-memory bounded channel.
  2. A background goroutine drains the channel and submits reports to the appropriate Exchange via ReportUsage RPC.
  3. Reports approaching their deadline are prioritized.
  4. Failed submissions are re-enqueued with exponential backoff.
  5. Reporting never blocks the Fetch call — it is entirely non-blocking.
type ReportingConfig struct {
// Whether to auto-submit usage reports after each Fetch. Default: true.
AutoReport bool
// Maximum number of pending reports before blocking. Default: 1000.
MaxPendingReports int
// Retry interval for failed report submissions. Default: 30s.
RetryInterval time.Duration
}
type pendingReport struct {
Report *forav1.UsageReport
Deadline time.Time // obligation window end
Retries int
NextTry time.Time
}
  • Capacity: bounded channel, default 1000 pending reports
  • Overflow: if the channel is full, the oldest report is dropped and a warning is logged. This is a soft failure — the Exchange may eventually block the agent for overdue reports (DENIAL_REASON_REPORTING_OVERDUE), but the current fetch is not affected.
  • Priority: reports approaching their deadline are submitted first
  • Retry: exponential backoff — 30s, 60s, 120s, max 10 minutes

UsageReportResponse now returns a report_id — an Exchange-assigned identifier for the accepted report. The SDK stores this on FetchResult.ReportID.

The report_id is required for filing disputes. The dispute chain enforces reporting-before-disputing:

UsageReport -> UsageReportResponse{report_id} -> DisputeRequest{report_id}

If the agent needs to dispute a transaction, it must have a valid report_id. The SDK tracks this automatically when AutoReport is enabled.

The Usage message now includes structured attribution reporting via CitationFormat and AttributionDetail:

type Attribution struct {
Format CitationFormat // e.g., CITATION_FORMAT_INLINE, CITATION_FORMAT_FOOTNOTE
Details []AttributionDetail // per-asset attribution specifics
}

The SDK populates attribution fields in the UsageReport when the caller provides them via FetchResult or ReportUsage.

Auto-reporting belongs to the deferred layer. ReportUsage itself ships, in all three languages, and this is its real shape:

// 4. Report what was used. ConsumedQuantity is the billed quantity, so a report
// without it bills nothing. Content.Body holds the fetched bytes, and function
// tokens come from the generated vocabulary rather than a string literal.
resp, err := client.ReportUsage(ctx, &forav1.UsageReport{
Exchange: "exchange.example", // the Exchange that issued the offer
TransactionId: item.GetTransactionId(),
BillingId: item.GetBillingId(),
Usage: &forav1.Usage{
ConsumedQuantity: int32(len(content.Body)),
Function: []string{functiontokens.AiInput},
},
})
if err != nil {
return err
}
// resp.GetReportId() is what a later dispute references.

The report is a forav1.UsageReport — the generated wire type — rather than an SDK-specific struct, and the verb returns a *forav1.UsageReportResponse carrying report_id. The client routes the report to the Exchange the offer named, resolved from that Exchange’s own manifest, never to whatever base URL happened to be configured.

Each transaction may carry a ReportingObligation that specifies:

  • Whether reporting is required or optional
  • The reporting window (deadline by which the report must be submitted)
  • What fields are required in the report

Tracking those deadlines belongs to the deferred layer. The obligation itself ships: it arrives on the transaction item as reporting_obligation, an ordinary generated wire field you can read. The shipped client starts no timer over it and keeps no queue, so meeting the window is the application’s job. If an Exchange denies a later transaction with DENIAL_REASON_REPORTING_OVERDUE, that reason arrives on the denial and the application decides what to re-send.

A Close(ctx) that blocks while a background queue drains belongs to the deferred layer, along with the queue itself. The shipped client holds no report queue: every ReportUsage call is one RPC that has either been accepted or returned an error by the time it returns, so there is nothing pending at shutdown and nothing to lose.

The Python client is a context manager (with Client(config) as client:) and closes its HTTP transport on exit. The Go client borrows the *http.Client you give it through connect.WithHTTPClient, so its lifetime stays yours.

The SDK emits structured log events for every significant operation:

The log events below belong to the deferred layer. The shipped client takes no logger: it emits no log lines of its own, on the view that a library writing to a process’s log stream is the application’s decision rather than the library’s.

Observing the shipped client is done with a Connect interceptor, which sees every RPC on its way out and back:

client := connect.NewClient(baseURL,
connect.WithInterceptors(loggingInterceptor),
// ... signer, requester, keys, endpoint resolver
)

What a failed call gives you to log is on Fetch Flow: CallError.Kind for the class, ReasonOf() for the peer’s own token, and connect.ErrorDetailFrom for the typed reason when the peer sent one.

EventLevelFields
fora.fetch.startInfourl, domain
fora.discovery.cache_hitDebugdomain, exchange_count
fora.discovery.cache_missInfodomain
fora.discovery.fora_jsonInfodomain, exchange_count, latency_ms
fora.supply.queryInfoexchange, uri_count, latency_ms
fora.selection.winnerInfoexchange, offer_id, unit_cost, subscription_id
fora.budget.checkDebuglayer, limit, current, requested
fora.budget.exceededWarnlayer, limit, current, requested
fora.transaction.executeInfoexchange, offer_id, latency_ms
fora.transaction.deniedWarnexchange, reason
fora.content.fetchInfourl, status_code, latency_ms, content_length
fora.report.enqueueDebugtransaction_id, deadline
fora.report.submitInfotransaction_id, accepted
fora.report.failedWarntransaction_id, error, retry_count

The SDK exposes Prometheus-compatible metrics via an optional metrics.Handler:

MetricTypeDescription
fora_fetch_totalCounterFetches by status (success, budget_exceeded, no_exchange, denied, error)
fora_fetch_duration_secondsHistogramEnd-to-end Fetch latency
fora_supply_query_duration_secondsHistogramPer-Exchange query latency
fora_transaction_duration_secondsHistogramPer-Exchange transaction latency
fora_budget_spent_totalCounterCumulative spend by currency
fora_budget_remainingGaugeRemaining budget by scope
fora_reports_pendingGaugePending usage reports
fora_reports_overdueGaugeReports past their deadline
  • The SigningKey (Ed25519 private key) is stored in the Config struct and never serialized, logged, or included in error messages.
  • Ed25519 signatures are computed per-request. The private key produces an RFC 9421 HTTP Message Signature in the HTTP headers of each DiscoverResources and ExecuteTransaction request.
  • The Exchange verifies signatures using the agent’s registered public key (looked up by the agent’s domain + signature keyid from its WellKnownManifest, not by any billing handle). The private key never leaves the agent.
  • All Exchange RPCs use HTTPS (TLS 1.2+)
  • Content fetch (signed URL) uses HTTPS
  • fora.json discovery uses HTTPS
  • No plaintext HTTP, even for localhost development (use http://localhost only with explicit opt-in)
CategoryWhat It TestsTooling
Unit: selectionunit_cost ranking, subscription preference, dedupTable-driven tests, no I/O
Unit: budgetPer-request/session/period enforcement, edge casesIn-memory tracker
Unit: reportingQueue overflow, deadline priority, retry backoffFake clock
Integration: mockFull Fetch flow against MockExchangetestutil.NewMockExchange
Integration: referenceFull Fetch flow against reference ExchangeReal Connect server
E2ESDK to reference Exchange to reference CDN to reportDocker compose