Cost Tracking
Cost tracking in the Ferro Labs AI Gateway has two halves: seeing what each request costs, and capping what a key can spend. Both rely on the same ingredient โ per-model token prices from the model catalog.
Where prices come fromโ
Prices live in the model catalog (models/catalog.go). Each model carries a
Pricing block whose fields are token rates in USD per one million tokens:
input_per_m_tokensโ cost per 1M prompt tokensoutput_per_m_tokensโ cost per 1M completion tokenscache_read_per_m_tokens/cache_write_per_m_tokensโ prompt-cache rates
These are pointers: a nil rate means the field does not apply to that model's
mode โ it does not mean free. Use 0 for genuinely free models.
The catalog loads from a remote release with an embedded fallback:
- Remote โ fetched from the latest
model-catalogGitHub release (catalog.json) during startup, before the listener binds, with a 10-second timeout by default. Override it withFERRO_MODEL_CATALOG_TIMEOUT(a Go duration); set it to0to skip the remote fetch entirely โ useful for air-gapped deployments that only want the embedded catalog. - Embedded fallback โ a bundled
catalog_backup.jsoncompiled into the binary, used whenever the remote fetch or parse fails, or is skipped. The gateway never fails to start because the catalog is unavailable. - 24h refresh โ a background ticker reloads the catalog every 24 hours; a failed refresh keeps the currently loaded catalog.
Override the source with the FERRO_MODEL_CATALOG_URL environment variable โ
useful for air-gapped deployments or enterprise custom pricing:
export FERRO_MODEL_CATALOG_URL="https://pricing.internal/catalog.json"
export FERRO_MODEL_CATALOG_TIMEOUT="0" # skip the remote fetch entirely
Prompt-cache pricingโ
A provider reports a cached prompt as PromptTokens inclusive of
CacheReadTokens. When a catalog entry sets cache_read_per_m_tokens, the
cached subset is billed at that rate and the remainder at the input rate, so
it is never billed twice. When a catalog entry sets no cache-read rate, the
whole prompt bills at the input rate โ an unpriced dimension bills as a
visible over-report rather than silently as zero. cache_write_per_m_tokens
prices CacheWriteTokens, which sit outside PromptTokens and cost nothing
unless the rate is set. This is the same rule models.Calculate applies for
every catalog-priced cost figure below, and the same rule the budget plugin
applies to its own operator-configured rates (see Capping spend).
Seeing spendโ
Per-request cost on tracesโ
After the upstream provider responds, the gateway computes the request's cost
synchronously and stamps it onto the completed-request span and event as the
ferro.cost.usd OpenTelemetry attribute (AttrFerroCostUSD in
observability/attributes.go). A breakdown is emitted alongside it:
| Attribute | Meaning |
|---|---|
ferro.cost.usd | Total request cost in USD |
ferro.cost.input_usd | Cost attributed to prompt tokens |
ferro.cost.output_usd | Cost attributed to completion tokens |
ferro.cost.cache_read_usd / ferro.cost.cache_write_usd | Prompt-cache costs |
ferro.cost.reasoning_usd | Cost of reasoning tokens |
ferro.cost.model_found | Whether the model matched a catalog entry |
Export these to any OTLP backend โ see Observability โ to chart spend per model, per route, or per tenant.
Durable spend: cost_usd on request-log rowsโ
With the request-logger plugin's persist: true and a configured request-log
store (REQUEST_LOG_STORE_BACKEND/_DSN, sqlite or postgres), every logged
row carries a cost_usd column โ the same catalog-priced estimate as
ferro.cost.usd, computed once and reused. cost_usd is null when the
catalog does not price the routed model, which is a coverage gap, not a claim
the request was free.
curl "http://localhost:8080/admin/logs?limit=20" \
-H "Authorization: Bearer $MASTER_KEY"
GET /admin/logs/stats โ spend by provider and modelโ
The stats endpoint aggregates the persisted log into per-dimension spend, so "what did the last week cost, broken down by provider and model" is one authenticated call rather than a trace-store query:
curl "http://localhost:8080/admin/logs/stats?since=2026-08-01T00:00:00Z" \
-H "Authorization: Bearer $MASTER_KEY"
The response includes a summary.cost_usd total (with unpriced_requests
counting completed requests the catalog could not price โ a floor on real
spend, not a total), plus by_provider and by_model maps, each entry
carrying its own cost_usd, request count, error count, and token totals.
This is the same data source the dashboard's Analytics page charts โ see
Dashboard for the UI. Both the log store and the stats
rollup require the request-log store to be configured; without it, spend
visibility falls back to traces (above) and the budget plugin's in-memory
counters (below).
Per-key counters from the budget pluginโ
When the budget plugin is enabled, it accumulates USD spend per API key in an
in-memory store. Those counters drive enforcement (below) and reflect live spend
since process start. They are in-memory only and do not survive a restart โ
for spend that does, use cost_usd on request-log rows.
GET /admin/keys/usageโ
The admin usage endpoint returns per-key activity, sorted and filterable, with a rolling summary:
curl "http://localhost:8080/admin/keys/usage?sort=usage" \
-H "Authorization: Bearer $MASTER_KEY"
It supports sort (usage or last_used), active, since, limit, and
offset, and returns a summary with total_keys, active_keys,
total_usage, and returned_keys. Note that this endpoint reports per-key
request counts and last-used timestamps, not USD โ for spend per key, read
the ferro.cost.usd traces, GET /admin/logs?api_key_id=<id> for that key's
priced rows, or the budget plugin's counters.
Capping spendโ
The budget plugin enforces a per-API-key USD ceiling. Register it at both the
before_request stage (to check the limit and reject when exceeded) and the
after_request stage (to record the completed request's cost). Both instances
share counters through a common store_id:
plugins:
- name: budget
type: guardrail
stage: before_request
enabled: true
config:
# Shared store identifier โ all instances with the same store_id share counters.
store_id: default
# Maximum cumulative USD spend allowed per API key. 0 = unlimited.
spend_limit_usd: 10.0
# Pricing used to calculate cost from token counts in the response.
input_per_m_tokens: 3.0 # USD per 1 million prompt tokens
output_per_m_tokens: 15.0 # USD per 1 million completion tokens
# Optional: unset bills the cached subset at the input rate (visible
# over-report, never silently free); set to price it separately.
cache_read_per_m_tokens: 0.30
cache_write_per_m_tokens: 3.75
# Maximum number of API keys tracked in memory. Evicts lowest-spend key at cap.
max_keys: 10000
- name: budget
type: guardrail
stage: after_request
enabled: true
config:
store_id: default
spend_limit_usd: 10.0
input_per_m_tokens: 3.0
output_per_m_tokens: 15.0
cache_read_per_m_tokens: 0.30
cache_write_per_m_tokens: 3.75
max_keys: 10000
When a key's accumulated spend reaches spend_limit_usd, the before_request
check rejects the request with HTTP 402 insufficient_quota โ not 429.
Waiting does not restore a spend cap the way it restores a rate-limit token, so
a 429's retry hint would just send every SDK into a backoff schedule it was
always going to exhaust; 402 tells clients to stop retrying instead.
The plugin sets its own input_per_m_tokens / output_per_m_tokens (and
optional cache rates) rather than reading the catalog, so it stays
self-contained; pick rates that match the models you route. If
spend_limit_usd is set but every rate (input, output, and both cache rates)
is 0, the plugin refuses to start โ cost would always be 0 and the limit
would never bite.
Two things worth knowing about how the check runs:
- Soft cap, not a reservation. The
before_requestcheck only reads already-committed spend; it never reserves the request's eventual cost. A bounded number of concurrently in-flight requests for the same key can all pass the check and collectively overshoot the limit once each completes โ bounded by in-flight count ร per-request cost, not unbounded. A hard pre-authorization cap is deliberately out of scope: a reservation that leaks on every error, cancellation, or circuit-open response would permanently pin a key at its cap. - Per-turn, inside an agentic tool loop. Guardrail and budget plugins
re-run at
before_requeston every MCP tool-loop turn. The budget check adds the loop's running cost so far (Measurements.CostUSD) to the stored spend before comparing against the limit, so a key can be cut off mid-loop rather than only between requests โ the loop is the one place a single request can spend without bound.
Cache-served responses (from the response-cache plugin) skip cost recording
entirely: nothing was billed upstream, so nothing is added to the key's spend.
Per-key budget enforcement requires the API key to be present at
pctx.Metadata["api_key"]. In bare OSS that field is populated whenever a
request carries a bearer token โ issued key or MASTER_KEY โ validated by the
gateway's own auth middleware, so budget tracking is live for any authenticated
deployment (the default; see Auth). Requests made under
ALLOW_UNAUTHENTICATED_PROXY=true carry no key and are not tracked or
rejected by this plugin. The counters themselves are in-memory and reset on
restart โ for spend enforcement that survives a restart, use the durable
cost_usd request-log data above alongside your own alerting, or Ferro Labs
Managed's server-side budget controls.
Relatedโ
- Dashboard โ the embedded Analytics page charting spend,
tokens, and latency from the same
/admin/logs/statsdata. - Observability โ exporting
ferro.cost.usdand other attributes to OTLP backends. - Budget plugin โ full config reference and validation rules.
- Plugins โ plugin stages and the built-in plugin set.
- Rate limiting โ capping request volume rather than dollar spend.