LLM models and keys
Users pick models, never providers. A model ref is provider/model-id (pi format, e.g. openrouter/anthropic/claude-haiku-4.5); the provider prefix only decides which key to look up. OpenRouter is the default: one key covers every model namespace. Direct-provider refs (anthropic/<id>, google/<id>) work only if a key for that provider resolves.
Model resolution
For every LLM turn (bot reply, director decision, sandbox pi turn):
thread config `model` → agent `model` → `DEFAULT_BOT_MODEL` (cheap OpenRouter preset)Thread = “try this model for this conversation”, set rarely; it pins every participant. Agent = durable preference. OpenRouter ids resolve from pi-ai’s bundled catalog first, then OpenRouter’s live catalog, so a new model needs no arbe release; an unknown id still fails loudly. The director’s decision pins its own model id (DIRECTOR_DECISION_MODEL_ID in packages/core/dispatch/reply.ts) and takes only the provider from the thread.
Code: resolveReply in packages/core/dispatch/reply.ts; catalog in packages/core/pi/openrouter-models.ts.
Key resolution
One precedence for replies, director decisions, titles, cron parsing and delegated coding agents:
house BYOK secret → funded house key → clear refusalBoth live in the same house secret, OPENROUTER_API_KEY: funded_by = 'arbe' means arbe minted it, null means the house brought its own. Nothing on this path ever reads an operator model key. Without either, the refusal is visible in the thread: No model key for this house. Add OPENROUTER_API_KEY under house secrets. (HTTP: agent.not_configured, 400). Turn overlays are cached ~60s, so a rotated key lands within a minute; a failed secret read fails closed and is never cached.
Code: resolveTurnSecrets in packages/core/dispatch/turn-secrets.ts, the one resolver behind every seam.
Funded keys
Creating a house mints it an OpenRouter key under arbe’s account with a $5 lifetime ceiling and stores it as the house’s OPENROUTER_API_KEY. Minting is best effort and never blocks creation; the create response says funded_key: minted | failed | skipped. Setting your own key replaces funding and disables the minted key. Deleting the secret or the house deletes the key.
A few things here look wrong at first glance but are on purpose:
- The funded key goes into the sandbox. A bot with
run_commandcan read it withenv. That is accepted: the key is capped at $5, scoped to one house, and revocable by rotation. The alternative (proxying every sandbox model call through arbe) was rejected as not worth the moving parts for an alpha. - The ceiling is not reserved credit. All houses share arbe’s balance, which can run out first. A house at its own ceiling sees
budget_exceeded; arbe’s balance running out seesprovider_balance_exhausted. Both name BYOK as the fix; BYOK 402s stay plain provider errors. OpenRouter reports both as 402 with no distinguishing body, so arbe asks/api/v1/auth/keyfor the calling key’s remaining allowance to tell them apart. - Rotation is not a refill.
arbe house fund rotate --house <id>disables the old key, reads its usage, and mints a replacement with only the remaining allowance. - OpenRouter is the real limit; our ledger is just the estimate. The ledger’s cap (
houses.spend_cap_usd,assertWorkerSpendAllowed) is a fast pre-check and an optimistic estimate: it undercounts when a worker dies before recording, and OpenRouter lets one request overshoot the ceiling.arbe house fund status --house <id>shows the key’s real usage next to the ledger, with the gap labelled “unexplained”; that gap is a reconciliation discrepancy, not proof of outside use. - Owners cannot raise their own cap. Operators run
bun run --filter '@arbe/www' fund-limit -- <houseId> <usd>, which updates both the key ceiling and the ledger cap.fund-backfill [--dry-run] [houseId...]mints keys for houses that predate funding.
OPENROUTER_MANAGEMENT_KEY lives only on the www worker, with an optional OPENROUTER_WORKSPACE_ID naming the OpenRouter workspace keys are minted in (default workspace otherwise). It is never a house secret, sandbox variable or backstage secret; self-hosters who leave it unset get no funded keys and must BYOK. Code: packages/core/openrouter-keys.ts, apps/www/src/routes/api/houses/.
Spend attribution
Every paid seam calls recordUsage() (packages/core/usage.ts) with a key_source:
key_source | Who pays | Counts toward spend_cap_usd? |
|---|---|---|
worker | arbe (funded house keys or infrastructure keys) | yes |
house | house (BYOK secret) | no |
env | house (env-bound secret) | no |
The resolver calls a minted key funded; recordUsage folds that into worker at that one boundary. Sandbox usage looks up the house key’s provenance (cached 60s, failures not cached). Cap checks run only before an arbe-funded spend: replies, director decisions, sandbox, cron parsing, GIF search, paid vision indexing. A BYOK house over cap keeps getting LLM turns; only worker-only seams refuse.
One gotcha worth knowing: budget_check is SECURITY DEFINER on purpose, because usage_events has no select policy and an invoker-rights gate would sum zero rows (migration 20260611150000).
Exceptions
- Developer model proofs use
ARBE_PROOF_OPENROUTER_API_KEY, passed explicitly. - File readings of images and scanned PDFs use the backstage’s
GEMINI_API_KEY; they count against the house cap and are refused per file when over it. Text indexing makes no model calls (volumes).