Skip to content

Budgets and frontier (L6)

Outcome: Cap spend and configure ordered L6 providers.

Budgets

frontier:
  enabled: true
  daily_budget_usd: 5
  monthly_budget_usd: 50
  soft_budget_ratio: 0.8

Soft warnings then hard stop. The same frontier.soft_budget_ratio (default 0.8) drives USD soft bands: when spend/limit crosses the soft line but not the hard cap, responses still succeed and add x-daari-budget-warning: soft plus daari_meta.warning=budget_warning (OpenAI chat, Anthropic Messages, and Responses when X-Daari-Meta is set). Prometheus increments daari_soft_warnings_total{kind="budget"} on those 2xx responses; hard 402 bumps daari_rejects_total{kind="budget"} only. See Response headers and metrics.

Per-key budgets

A virtual key can carry its own caps, charged only against that key's own spend:

daari keys team-create eng --daily-budget 5
daari keys create ci-bot --daily-budget 2 --monthly-budget 20 --team eng --window 7d=10
daari keys create weekly-bot --window week=10
daari keys create contractor --window lifetime=50

Each window is checked independently. A key can carry several {duration, max_usd} windows at once (day/24h/daily, week/weekly/rpw/1w for ISO calendar weeks, month/30d/monthly, 7d, lifetime/total, …); team caps apply to every key on that team and the tighter of key vs team wins. The org frontier.* caps above remain an outer ceiling. A key over budget gets 402 naming the window that tripped and when it resets:

{
  "error": {
    "type": "budget_exceeded",
    "message": "Virtual key daily frontier budget ($2.0000) exceeded — $2.4310 spent. Resets at 2026-08-28T00:00:00+00:00.",
    "client_id": "ci-bot",
    "window": "daily",
    "budget_usd": 2.0,
    "spend_usd": 2.431,

    "reset_at": "2026-08-28T00:00:00+00:00",
    "scope": "key"
  }
}

Lifetime caps

lifetime (aliases: total, all) sums all-time frontier spend for the key (or team) from the usage ledger — no calendar reset, no rollover. The 402 payload names window: "lifetime" and omits a reset time. Same shape works for --window-requests lifetime=1000. Per-end-user lifetime caps use key metadata user_lifetime_usd_cap (charged via the user ledger, scope: "user").

Temporary budget boosts

When a team hits its monthly cap mid-incident, grant a bounded, auto-expiring increase. Expiry is evaluated at enforcement time — no cron:

daari keys budget-boost <key_id> --usd 25 --until 2026-09-23T00:00:00Z
daari keys team-budget-boost eng --usd 100 --until 2026-09-23T12:00:00Z
daari keys show <key_id>   # lists active boosts

Grant and expiry emit audit actions budget.boost.grant / budget.boost.expire. Active boosts add to the effective USD (and optional request) limit on every matching window for that scope.

Per-end-user caps on a shared key

When many humans share one virtual key (a team agent), pass the OpenAI user field so spend is attributed per person. Optional --user-daily-cap denies only the user who crossed it (scope: "user"); others on the same key keep working. Requests without user are attributed to unknown and are never user-capped.

daari keys create shared-agent --user-daily-cap 2
daari usage --by-user
# or: daari report --by-user

GET /v1/daari/report includes a users array (client_id, user_id, counts).

Window rollover (opt-in)

By default unused headroom disappears at reset. Set rollover: true on a window so leftover USD carries into the next period's effective limit:

{ "duration": "month", "max_usd": 100, "rollover": true, "rollover_cap_multiple": 2.0 }

CLI: daari keys create bot --window month=100:rollover.

At each period boundary, unused headroom (prev_effective − spent, floor 0) becomes carry. Effective limit is min(base + carry, base × rollover_cap_multiple) (default cap 2× the base) so idle keys cannot accumulate forever. Carry is persisted on the usage ledger (budget_window_state) so SQLite and Postgres replicas agree. Headers, 402 bodies, and budget alert webhooks all report the effective limit and remaining.

Clients do not have to wait for the 402. Every successful response to a budgeted key carries x-daari-budget-remaining / -limit / -window / -reset / -scope for the window it will hit first (least USD left across key and team), and the 402 repeats them with remaining 0 plus Retry-After. In the soft band, successful responses also carry x-daari-budget-warning: soft (omitted on hard 402). See Response headers.

Operator alerts

A webhook fires when a request pushes a key or team window across a threshold (default 80% and 100%) — USD or request-count quotas. Empty URL disables. The POST is a background task: a down hook is logged (budget.alert_failed) and never delays or fails the chat response.

alerts:
  budget_webhook_url: https://hooks.example/daari-budget   # Slack / ntfy / PagerDuty
  budget_webhook_secret: secret://env-file/~/.daari/secrets.env#BUDGET_WEBHOOK  # optional
  budget_thresholds: [0.8, 1.0]
  member_budget_thresholds: []   # optional; e.g. [0.8, 1.0]

member_budget_thresholds is empty by default, so key and team alerts are unchanged. When a team key sets user_daily_usd_cap and this list is non-empty, a named user crossing one of those ratios of the daily cap POSTs the same webhook with scope: "member", team_id, user_id, window, spent, and limit. That crossing fires once per member, window, and threshold (Redis SET NX EX when configured; a Redis error still delivers and logs budget.alert_dedupe_degraded). Signing uses the same X-Daari-Signature secret.

Payload (never includes key material):

{
  "scope": "key",
  "id": "a1b2c3d4",
  "name": "ci-bot",
  "window": "daily",
  "limit_usd": 5.0,
  "spent_usd": 4.12,
  "remaining_usd": 0.88,
  "threshold": 0.8,
  "reset_epoch": 1756944000
}

Request-quota crossings use the same envelope with quota: "requests" and limit_requests / spent_requests / remaining_requests instead of the USD fields.

When budget_webhook_secret is set (plaintext or a secret:// ref), every POST carries X-Daari-Timestamp (unix seconds) and X-Daari-Signature (HMAC-SHA256 hex of timestamp + "." + body over the exact request bytes). Unset keeps the previous unsigned POST (no new headers). Reject timestamps older than ~5 minutes to block replays.

Rotate without a hard cutover via daari alerts rotate-secret budget --secret NEW (or key-expiry). That moves the current secret to budget_webhook_secret_previous (and stamps budget_webhook_secret_rotated_at). Signing always uses the new current; during grace the header lists both digests comma-separated (current,previous) so receivers can verify against either secret. Clear *_webhook_secret_previous after all receivers accept the new secret — daari doctor tips when a previous secret has been in place for ~7 days.

import hashlib, hmac, time

TOLERANCE = 300  # seconds

def verify(body: bytes, signature: str, secret: str, timestamp: str,
           previous: str = "") -> bool:
    if abs(time.time() - int(timestamp)) > TOLERANCE:
        return False
    secrets = [s for s in (secret, previous) if s]
    digests = [p.strip().lower().removeprefix("v1=") for p in signature.split(",") if p.strip()]
    for sec in secrets:
        expected = hmac.new(
            sec.encode(), f"{timestamp}.".encode() + body, hashlib.sha256
        ).hexdigest()
        if any(hmac.compare_digest(expected, d) for d in digests):
            return True
    return False

Each (scope, id, window, threshold, reset_epoch) fires at most once until that window resets. With cache.backend: redis (the same Redis as L0/L1 and rate limits), the fleet claims that tuple with SET NX EX before delivery; the TTL runs until the window reset so keys expire on their own. Without Redis, dedupe stays in-memory on the process that handled the request — two replicas can notify twice. A Redis error still delivers the alert and logs budget.alert_dedupe_degraded (never drop a page because the claim store is down). Each fire writes a budget.alert audit row and increments daari_budget_alerts_total{scope,threshold}.

Key expiry alerts

Budget pages catch spend cliffs; key-expiry alerts catch credential rotation before a 90-day key goes dark. The same alerts block opts in a proactive plane (doctor + Prometheus + webhook) that does not wait for a 401 key_expired at request time:

alerts:
  key_expiry_webhook_url: https://hooks.example/daari-key-expiry   # empty disables
  key_expiry_webhook_secret: secret://env-file/~/.daari/secrets.env#KEY_EXPIRY_WEBHOOK  # optional
  key_expiry_warn_days: 14   # doctor / gauge / webhook window (env: DAARI_ALERTS__KEY_EXPIRY_WARN_DAYS)
  • daari doctor warns on keys entering the warn window and counts expired-but-not-revoked (key_expiry check). When a webhook URL is set without a signing secret, doctor tips key_expiry_webhook_secret.
  • Scrape-time gauge daari_virtual_keys{status} exposes active, expired, expiring_soon, and revoked (stock Grafana: Virtual keys by status). See Prometheus metrics.
  • When key_expiry_webhook_url is set, each scrape that sees a key enter expiring_soon POSTs once (Redis SET NX dedupe when available; fail-open on Redis errors). Down hooks log key_expiry.alert_failed and never delay the scrape.

Payload (never includes key material or the secret prefix):

{
  "event": "key_expiry",
  "status": "expiring_soon",
  "key_id": "a1b2c3d4",
  "name": "ci-bot",
  "expires_at": "2026-10-24T12:00:00+00:00",
  "warn_days": 14
}

key_expiry_webhook_secret reuses the budget signing headers: X-Daari-Timestamp + X-Daari-Signature (HMAC-SHA256 of timestamp + "." + body). Verify with the same helper as budget webhooks above (including key_expiry_webhook_secret_previous during rotation). Unset keeps an unsigned POST. Rotate with daari alerts rotate-secret key-expiry --secret NEW.

Existing keys that only have daily_budget_usd / monthly_budget_usd are migrated to day / month windows on first open; behavior is unchanged.

Only frontier (L6) usage counts toward USD budgets. Local tiers and cache hits are free and never consume a USD budget. Spend is priced per model from pricing.models, so a key's remaining allowance reflects the models it actually used.

Request-count quotas

USD caps cannot meter shared local GPU capacity: local tiers cost $0.00, so a key can monopolize the fleet without touching a budget window. RPM only bounds bursts. Request-count quotas close that gap.

daari keys create gpu-bot --window-requests 1d=5000 --window 1d=5
daari keys team-create eng --window-requests 1d=20000

YAML / SSO budget_windows entries accept the same shape:

budget_windows:
  - duration: day
    max_usd: 5
    max_requests: 5000

Semantics match USD windows: multiple durations are checked independently, team caps apply to every key on the team, and the tighter of key vs team wins per dimension (USD and requests merge independently). Counting is ledger-backed (requests − cache_hits on client_usage), so quotas hold across replicas when observability.backend=postgres. Local-tier requests count; L0/L1 cache hits do not — a cache hit costs nothing to serve.

Exceeding a request quota returns the same 402 budget_exceeded shape with quota: "requests", budget_requests / spend_requests, plus x-daari-quota-requests-remaining / -limit (and Retry-After). Successful responses for a quota-bound key carry the remaining/limit headers so clients can back off before the hard stop. daari keys list prints live req used/cap next to USD usage for each window (appends soft when the window is in the soft band). daari report / GET /v1/daari/report include a request_quotas array with the same used/cap/remaining/soft fields.

Soft band (same frontier.soft_budget_ratio, default 0.8): when used/cap crosses the soft line but not the hard cap, responses still succeed and add x-daari-quota-requests-warning: soft plus daari_meta.warning = "request_quota_warning" on OpenAI chat, Anthropic Messages, and Responses (when X-Daari-Meta is set). Operator webhooks from alerts.budget_webhook_url also fire on request-quota threshold crossings with quota: "requests" and limit_requests / spent_requests / remaining_requests (USD payloads unchanged).

Pricing

Spend is computed per model and per direction from pricing.models, in USD per 1M tokens:

pricing:
  models:
    gpt-4o:
      input_per_1m: 2.50
      output_per_1m: 10.00
      cached_input_per_1m: 1.25
    my-self-hosted-model:
      input_per_1m: 0.0
      output_per_1m: 0.0

Names match on longest prefix, so a gpt-4o entry also prices gpt-4o-2024-08-06. A vendor prefix resolves the same way: anthropic.claude-fable-5-1 and models/gemini-3.8-flash use the shipped id. Anything unmatched falls back to the flat usage.frontier_price_per_1k_tokens, which ignores direction and will misprice a model whose output rate differs sharply from its input rate. daari doctor warns about models being billed at that fallback, so add an entry when you adopt a new model or your budgets will drift from your real invoice.

Shipped list prices (captured 2026-09-07)

The default table still includes the 2024 entries (gpt-4o, gpt-4o-mini, claude-3-5-sonnet, claude-3-5-haiku, claude-3-opus) at their previous rates. Current flagship and workhorse models, USD per 1M tokens:

Model Input Cached input 1h cache write Output Source
claude-fable-5-1 10.00 0.25 20.00 50.00 Fable 5.1
claude-opus-5 5.00 0.50 10.00 25.00 Anthropic pricing
claude-opus-5-5 4.00 0.20 8.00 20.00 LiteLLM 1.104 / Anthropic Opus 5.5; cache read halved 2026-10-07; non-default temperature/top_p/top_k stripped
claude-sonnet-5 2.00 0.20 4.00 10.00 Anthropic pricing
claude-sonnet-5-5 2.00 0.10 4.00 10.00 Sonnet 5.5; cache read halved 2026-10-07; non-default temperature/top_p/top_k stripped
claude-haiku-4-5 1.00 0.10 2.00 5.00 Anthropic pricing
claude-haiku-5-5 0.10 0.01 0.20 0.50 Haiku 5.5 (2026-10-07); 5m write $0.125 (≥100K $0.625); 1h write $0.20 (≥100K $1.00); adaptive thinking maps budget_tokens → effort
gpt-6-astra 10.00 1.00 — 50.00 OpenAI; ≥272K input → $20 / $2 / $75
gpt-6.1-sol 2.00 0.10 2.50 10.00 OpenAI (2026-09-29); 5× cheaper than Astra; no published above-272K surcharge
chat-latest 5.00 0.50 — 30.00 Rolling ChatGPT Instant alias (updated 2026-10-07); underlying snapshot moves — prefer gpt-6-astra for production pins
gpt-6-luna 0.10 0.01 — 0.50 Chat/Responses. POST /v1/decisions is input-only: $0.10 in, $0 out, $0 cache
gpt-5.6 / gpt-5.6-sol 4.00 0.40 — 20.00 OpenAI promo through 2026-11-21 (Sol)
gpt-5.6-terra 2.00 0.20 — 12.00 OpenAI API pricing
gpt-5.6-luna 0.20 0.02 — 1.20 OpenAI API pricing
gemini-3.8-flash 0.75 0.075 — 3.75 Gemini pricing intro rate through 2026-12-31
grok-4.7 2.00 0.50 — 6.00 xAI / LiteLLM 1.105 RC lab bar (2026-10-04); OpenRouter ~$1.6/$4.8

gpt-5.6 is the Sol alias. Longer keys win, so gpt-5.6-luna is not priced as Sol. Gemini 3.8 Flash's published standard rate becomes $1.50 / $7.50 on 2027-01-01; the shipped default is the intro rate in effect now. Override pricing.models when that date passes or when a provider changes a quote.

Context-threshold pricing

Optional fields on a pricing.models entry switch the entire request to a higher rate once prompt tokens reach a threshold (OpenAI's long-context surcharge pattern):

pricing:
  models:
    gpt-6-astra:
      input_per_1m: 10.0
      output_per_1m: 50.0
      cached_input_per_1m: 1.0
      input_threshold_tokens: 272000
      above_input_per_1m: 20.0   # 2×
      above_output_per_1m: 75.0  # 1.5×

Below the threshold, rates are unchanged. At or above it, input/output (and a proportionally scaled cached-input rate) use the above_* values. Cost headers, the usage ledger, and budget enforcement all go through the same cost_usd path, so a 402 can fire before a 2× request is dispatched.

Service-tier pricing

OpenAI / Anthropic / OpenRouter service_tier is forwarded on L6 hops and multiplies cost_usd: flex is 0.5×, priority / fast are 2× (OpenAI renamed Priority to Fast mode; both names bill the same), ultrafast is 6× for gpt-6.1-sol and gpt-6-astra (Astra Ultrafast is US/global only — an region_pin: eu key clears the tier with daari_meta.warning= ultrafast_eu_unsupported; unsupported models get ultrafast_unsupported_model), standard / default / auto / missing is 1×. Unknown values log service_tier_ignored and bill as standard.

OpenAI regional processing (data residency) adds a further 1.10× uplift on models released on or after 2026-03-05 when a key/team region_pin is us or eu; unset pin bills the standard (or Fast/Ultrafast) rate with no residency premium (daari doctor pricing notes). FedRAMP routing is not invented here.

Anthropic cache_control TTL write rates

Anthropic prompt-cache markers on system/tool blocks (cache_control.ttl 5m or 1h) are preserved on the L6 Messages payload. Default auto-hint remains {type: ephemeral} (5-minute). When the request asks for ttl: 1h, cost_usd(..., cache_write_tokens=…, cache_ttl="1h") bills writes at cache_write_1h_per_1m (2× base input on the shipped Claude table). Missing TTL or 5m keeps today's rate (write tokens at input_per_1m).

Providers / fallback

Configure frontier.providers (ordered list) for OpenAI-compatible bases, Anthropic, OpenRouter, etc. Circuit breakers and key rotation ship with the L6 pool. Each entry may set timeout_s, retry_attempts, and retry_backoff_s; unset fields inherit upstream.frontier_timeout_seconds / upstream.retry. A provider's exhausted retries advance the chain, and one slot's timeout does not consume the next slot's budget. POST /v1/daari/route/preview and daari route preview show the resolved policy on policy / chain. A provider whose provider is anthropic/claude, or whose base_url contains anthropic.com, is sent native Messages API payloads (POST …/messages, x-api-key) rather than an OpenAI /chat/completions body.

Clients may send OpenRouter's provider object (zdr, sort, order, max_price, …). daari stores it on the request, passes it through when the L6 slot is OpenRouter, and fails closed (HTTP 400) if zdr: true and no configured slot declares zdr: true. Chosen provider, usage.cost, and cached tokens land in daari_meta.

OpenRouter slot (G3)

Use their catalog; do not clone it. The documented slot:

frontier:
  enabled: true
  providers:
    - id: openrouter
      base_url: https://openrouter.ai/api/v1
      model: openrouter/auto   # or anthropic/claude-sonnet-4.5, etc.
      api_key_env: OPENROUTER_API_KEY
      zdr: false               # set true if the key is ZDR-only
      # timeout_s: 30          # optional; else upstream.frontier_timeout_seconds
      # retry_attempts: 2      # optional; else upstream.retry.attempts
      # retry_backoff_s: 0.5   # optional; else upstream.retry.base_delay_ms / 1000

OPENROUTER_API_KEY (or DAARI_FRONTIER_API_KEY) is BYOK — never committed. Outbound calls send HTTP-Referer and X-Title: daari for app attribution. L6 daari_meta records cost_usd (upstream) and daari_cost_usd: 0.

Local model suffixes: daari:floor is the smallest capable local tier (L3); daari:nitro prefers a warm / low-latency local backend. These do not call OpenRouter.

API keys via environment (never commit):

export DAARI_FRONTIER_API_KEY=sk-...
# or provider-specific envs documented in config reference

PII / slim

frontier:
  scrub_pii: true
  slim_prompts: true

Verify

Force L6 only in a test env with a tiny budget; confirm soft warning then block in traces/daari_meta.

Next

→ Config reference