Subagent Models & Provider Credentials
The Subagent Models dashboard (/dashboard/models) allows authenticated operators to manage LLM provider credentials, model routing profiles, token pricing, budget limits, and safe tool permissions for coding subagents (agent_* tools).
Architecture & Security Boundary
- Zero Credential Exposure: Provider API keys are write-only, encrypted at rest using AES-256-GCM via
SecretKeyring, and projected dynamically into Model Gateway RAM. Provider secrets are never written to workspace directories or injected into subagent containers. - Dynamic Hot Reload: Changes to model profiles and credentials take effect immediately without requiring service restarts or container redeployments.
- Lease Pinning: Subagents running with an active capability lease remain bound to their exact immutable profile revision. When rotating a credential, in-flight requests safely complete with the prior key while subsequent requests seamlessly use the new key.
Managing Provider Credentials
Operators can configure API keys for these providers. Endpoints, accepted auth headers, and supported API modes come from the provider registry (packages/contracts/src/model-provider-registry.ts), which authenticated dashboard sessions can read at GET /api/v1/model-providers.
| Provider | Chat Completions | Responses | Auth header |
|---|---|---|---|
| OpenAI | api.openai.com/v1/chat/completions | api.openai.com/v1/responses | Bearer |
| Anthropic Claude | api.anthropic.com/v1/chat/completions (OpenAI SDK compatibility) | Not supported | Bearer or x-api-key |
| OpenRouter | openrouter.ai/api/v1/chat/completions | openrouter.ai/api/v1/responses | Bearer |
| Google Gemini | generativelanguage.googleapis.com/v1beta/openai/chat/completions | Not supported | Bearer |
| DeepSeek | api.deepseek.com/chat/completions | Not supported | Bearer |
| OpenCode Go | opencode.ai/inference/go/openai/v1/chat/completions | opencode.ai/inference/go/openai/v1/responses | Bearer (the gateway also sends x-opencode-session; see OpenCode Go) |
| Custom | Your HTTPS URL on port 443 naming one fixed request path, with no userinfo, query string, or fragment (SSRF-validated; endpoints that need a query string such as ?api-version= are not supported, because the gateway sends only the URL path) | Your HTTPS URL | Bearer or x-api-key |
Saving a profile with an API mode its provider does not offer, or a credential with an auth header its provider does not accept, is refused with a message naming the supported values. A credential stored with an auth header its provider no longer accepts cannot back a profile: add a new credential with the supported header and select it on the profile.
Gemini profiles created before this fix were routed to a path Google does not serve and failed with 404. Profile revisions are immutable, so edit and save each Gemini profile once: the new revision uses the documented
v1beta/openaiendpoint. If the profile used the Responses API mode, switch it to Chat Completions in the same edit. If its credential was saved with thex-api-keyheader, add a new Gemini credential (Bearer) and select it, then delete the old credential.
Adding a Credential
- Navigate to Configuration $\rightarrow$ Models in the sidebar.
- Click + Add credential.
- Select the provider, enter a label, and paste your API key.
- Click Save credential. Keys are write-only and cannot be viewed in plaintext after submission.
Rotating a Credential
- Locate the credential in the Provider Credentials table.
- Click Rotate.
- Enter the new API key and confirm. The active version increments atomically in memory.
Model Catalog
The catalog answers "which models can this credential use, and what do they cost?" so a profile can be filled from a search instead of typed from memory. The runner builds it from two sources and returns only model metadata; the credential never leaves the runner.
| Source | What it proves | Shared? |
|---|---|---|
| The provider's own models endpoint, called with the selected credential and the auth header it was saved with | The model is available to that key (listed_for_credential) | No: only the principal that owns the credential, and only for its current key version |
| OpenRouter's public models list | Display names, context and output limits, capabilities, and prices | Yes: it is public metadata |
The provider listing is cached for a shorter time than the public list because it changes with your account. The cache lifetimes, failure backoff, fetch deadline, and refresh limits are owned by apps/runner/src/model-catalog/model-catalog-service.ts and apps/runner/src/model-catalog/provider-catalog.ts.
- OpenRouter credentials list OpenRouter's models directly, and their prices are the ones OpenRouter bills: confidence
exact. - Direct vendors (OpenAI, Anthropic, Gemini, DeepSeek) take their model IDs from the vendor's own listing. OpenRouter metadata is attached when the vendor ID matches the OpenRouter
vendor/modelID (canonicalModelId), and its price is labelledestimatebecause the vendor bills you, not OpenRouter. OpenRouter IDs are never sent to a vendor. - Without a credential, or when the vendor listing fails, OpenRouter's public entries for that vendor stand in with availability
public_catalog. These IDs are unverified: they are OpenRouter's naming with the vendor prefix removed, and nothing has confirmed that your key can call them. Check apublic_catalogmodel against the provider before saving a profile with it; the profile wizard enforces that check. - Custom providers have no catalog; enter the model ID yourself.
Every price carries its provenance: the source (provider, openrouter, user, or none when no source published a price), the source model ID, when it was retrieved, and its confidence (exact, estimate, or unknown). An unknown price is null, never zero, so a missing price is never treated as free. Capabilities (tools, reasoning, vision) are reported only when provider or OpenRouter metadata says so.
Catalog prices follow the same rules as saved revision prices, so a catalog price can be saved unchanged:
- Cache writes. OpenRouter publishes a default and a one-hour cache-write price. The catalog keeps the higher of the two, because the agent runtime can ask for long retention and the usage a provider reports does not say which write was billed.
- Tiers. An OpenRouter price override whose only condition is a minimum prompt length (
min_prompt_tokens) becomes a long-prompt tier intiers. A tier never inherits a base price: one without its own input and output price is not used, and a cache price it does not publish stays unknown. - Conditional pricing. Tiers are all or nothing. When any override cannot become a tier (it has another condition or lacks its own input and output price), when there are more than four, when two share a threshold, or when the tiers would break the tier rules once unknown cache prices are resolved for the selected provider, the entry lists no tiers at all and sets
hasConditionalPricing. The listed flat price then understates or overstates some requests, but a partial tier set is never shown as complete.
Freshness and refresh
Each result reports freshness per source. fresh means every source answered within its time-to-live. stale means at least one source failed and the last good copy, kept across runner restarts, is being served; the source shows an error category such as authentication, rate_limited, or timeout. unavailable means there is nothing to show yet. After a failure the runner backs off before asking that source again. A credential that is disabled or revoked is never sent to the provider; its listing reports credential_unavailable.
The dashboard reads the catalog at GET /api/v1/model-catalog (query parameters provider, credentialId, q, cursor, limit, capability) and forces a refresh with POST /api/v1/model-catalog/refresh. A refresh skips the cache and is rate limited per provider and credential. The public OpenRouter list is shared, so it has its own limit: refreshing several credentials in a row re-fetches each credential's listing but forces the public list only once per window. Schemas live in packages/contracts/src/model-catalog-schemas.ts.
Verifying a Connection
A connection check proves that a credential, model, and API mode work before a subagent depends on them. The runner sends the requests itself, so the key never reaches the browser, the API, or a workspace. Request it with POST /dashboard/api/v1/model-connection/verify (operation model_connection_verify; the input and result schemas are in packages/contracts/src/model-verification-schemas.ts):
{ "profileId": "coding-fast" }checks a saved profile's active revision: its credential, model, API mode, and upstream.{ "credentialId": "cred_…", "model": "…", "apiMode": "…", "customUpstreamUrl": "…" }checks unsaved settings. A draft never creates or changes a profile, revision, or credential. Without a model only the key is checked. A check of a model whose route resolves includes averificationRef; passing it asverificationRefwhen creating or updating a profile with the same route keeps the result on the new revision (see the profile wizard). A save that matches uses the reference up, and so does a save whose route differs: that save is not refused, its profile simply has nolastVerification, and the check must be run again. A save that fails leaves the reference unused. A draft for a Custom credential sends the key to thecustomUpstreamUrlyou submit, exactly as saving a profile with that upstream would, so only submit an upstream you trust with the key."checks": { "inference": false, "tools": true }turns the paid checks off or on. Inference runs by default when a model is known; the tool-call check is opt-in.
What is checked
| Check | How |
|---|---|
authentication | Lists the provider's models with the key (Anthropic uses its native x-api-key header). OpenRouter's list is public, so its key is checked at GET /api/v1/key. Custom providers skip this and rely on inference. A restricted key that may not list models is limited (access), not failed; when inference runs, it decides whether the key works. |
model_access | The model ID appears in the listing (Gemini's models/ prefix is ignored), or the model answered. A model missing from a complete listing fails only when no inference ran or the provider answered not found; if inference ran but proved nothing (rate limit, quota, timeout), or the listing was partial, it is limited. |
protocol | A tiny streamed request with a small output cap returns events of the expected API family: choices[].delta for Chat Completions, response.* for Responses. The cap uses the same field the model gateway sends for that provider. |
streaming_inference | The stream finishes with a finish reason, [DONE], or a completed response; token usage is captured when the provider reports it. An error inside the stream is classified like an HTTP error. |
tool_call | Optional. Forces a call to a harmless synthetic report_status tool. |
Outcomes
verified: every check that ran passed.limited: nothing proved the configuration wrong, but something could not be proven: a rate limit (rate_limited), exhausted credit (quota),timeout,network, a provider outage (upstream), or a model that does not accept tool calls. A rate limit or quota answer never marks a model unsupported. Retry later.failed: the provider proved the settings wrong.authis a rejected key (Gemini reports this as HTTP 400 or 403),accessis a key without permission,not_foundis an unknown model or path,unsupported_protocolis a refused request shape or the wrong API mode,invalid_responseis an answer that is not a usable stream, andforbidden_hostis an upstream that resolves to a private address.not_tested: no check ran.
Each check reports its errorCategory, httpStatus, and a short provider message with any key material removed.
Cost and limits
The inference and tool checks spend a small, capped number of tokens on the provider account behind the key. For a saved profile the result includes estimatedCostMicros from the revision's pricing; drafts report null. Checks are rate limited per principal, and a whole check, name resolution included, runs under one deadline that is shorter than the dashboard's request timeout; checks that run out of time are reported as limited (timeout). The limits and caps are owned by apps/runner/src/model-verification/. Every check is recorded in the audit log as model_connection.verified with its outcome, target, credential, model, API mode, elapsed time, and token counts, never the key.
The latest result appears as lastVerification on credentials and profiles. A profile's result belongs to the revision it checked, so saving a new revision shows it as not verified again, unless the save names a matching draft check; rotating a key does the same for the credential. Deleting a profile or credential deletes its results.
Configuring Model Profiles
Model profiles define how subagents interact with LLMs:
- Model Name: The provider-specific model identifier (e.g.
gpt-5.2-codex,claude-3.7-sonnet). - API Mode:
chat-completions(/v1/chat/completions) orresponses(/v1/responses), limited to the modes the provider offers. - Custom upstream URL: Required for a Custom credential. Editing the profile keeps its current URL unless you enter a new one; changing the API mode or switching to a different Custom credential requires entering the URL again.
- Token Pricing: Input and output token pricing in USD per 1M tokens, plus optional cache prices and long-prompt tiers (see Revision pricing and provenance).
- Budget Limits: Hard limits for maximum input tokens, output tokens, and cost per agent execution.
- Allowed Proxy Operations: Hard ceiling on permitted file and search operations from the exact 10 safe tools (
files_list,files_read,files_write,files_apply_patch,files_delete,files_move,files_mkdir,grep_search,symbols_search,symbols_references).
Revision pricing and provenance
Every saved revision pins its prices. Prices are integer micro-USD per one million tokens, and the contract lives in packages/contracts/src/model-pricing.ts and AgentModelPricingSchema in model-profile-schemas.ts.
- Cache prices.
cacheReadMicrosPerMillionTokensandcacheWriteMicrosPerMillionTokensare optional. Leaving one out, or sendingnull, records it as unknown, never as free. - Tiers.
tiersholds up to four long-prompt price levels; an empty list means flat pricing. Each tier applies once a request's prompt reaches itsminPromptTokens(at most 10,000,000); the base prices cover shorter prompts. Thresholds must strictly ascend, and no tier may charge less than the level below it. That rule applies to the prices the gateway charges, after unknown cache prices are resolved for the key's provider (see the table below). A save is refused withINVALID_INPUTwhen, for example, a tier states a cache read below the base's unstated one (which costs the base input price), or the base states a cache write and a tier leaves it out. State the cache prices on every level to avoid this. - Provenance.
priceProvenancerecords where the prices came from:source(provider,openrouter,user, ornone),sourceModelId,retrievedAt,confidence(exact,estimate, orunknown), andisOverride. A client cannot set provenance or confidence; the runner assigns them when the revision is saved:- A save that sends
catalogPriceRef: { "sourceModelId": "vendor/model" }names the catalog model its prices came from. The reference must be the catalog entry for the model the revision routes to: for an OpenRouter profile, that model ID itself; for a direct vendor, the catalog model the profile's model matches (the one the catalog search shows for it). The runner resolves the routed model in its own cached catalog, without a network call. When the reference names that entry and the submitted input, output, cache read, cache write, and tier prices all equal its price, the catalog's provenance is copied withisOverride: false: confidenceexactfor OpenRouter,estimatefor a direct vendor. - Pricing that differs in any price or tier is your override:
source: "user",isOverride: true, confidenceunknown, with the routed model's catalog entry and its retrieval time kept for context. - A save without a reference, with a reference to any other model, or for a model the cached catalog does not list, is recorded as your override with no source model. Revisions saved before provenance existed read back the same way.
- A save that sends
Pinned prices never drift. Revisions are immutable, and catalog refreshes never write to them. An edit that does not submit pricing copies the current revision's prices, cache prices, tiers, and provenance unchanged, and keeps its limits unless the edit changes them. Submitting only the input and output prices, unchanged, counts as not submitting pricing. If the edit changes the model or the key, the copied prices are recorded as your override of unknown confidence, because they were set for the previous model. A key for another provider can resolve unknown cache prices differently, so switching keys is refused with INVALID_INPUT when the copied tiers would break the rule above. Any other submitted pricing replaces all of them: send the cache prices and tiers again to keep them. The new prices get provenance by the rules above, so they are your override unless they match the catalog price they reference.
Repricing review. Listing profiles (model_profile_list) adds a pricingReview to each profile with an active revision. It compares the revision's pinned prices with the cached catalog price for the model the revision routes to, matched the way the catalog search matches it; the stored sourceModelId is never used to pick the price. The schema is ModelPricingReviewSchema in model-profile-schemas.ts.
repriced: an input, output, cache read, cache write, or tier price differs from the catalog by at leastREPRICING_WARNING_PERCENT(10%, inmodel-catalog-schemas.ts) in either direction; a pinned price of zero now has a catalog price; a pinned cache price is no longer published by the catalog (listed withcatalog: null); a tier exists on only one side. A rise means cost estimates and caps are understated, a fall means they are overstated. A cache price the revision leaves unknown is not compared, because it is charged at the conservative default below.changeslists each difference with the pinned price, the catalog price, and the signed percentage.current: nothing differs by that much.unknown: the runner has no cached catalog price for the model, for example before the catalog was first searched, for a Custom provider, or for a model the catalog does not list.
The state reflects only prices a revision can hold, so editing the profile to the catalog price always clears repriced. Catalog conditional pricing that no revision can express is reported separately as catalogHasConditionalPricing: true, an informational note: for some requests the provider charges a price the revision does not record, which may be lower or higher than the pinned estimate. It never changes state, and a profile whose prices match the catalog reads current with the note set.
isOverride repeats the revision's provenance: for your own prices the difference is information, not a sign that the price is stale. Listing never makes a network call: the review reads the last cached catalog, whatever its age. The OpenRouter catalog is kept across restarts, except that a copy saved in an older format is not used: until the next catalog search fetches a new one, reviews of every catalog-priced profile, OpenRouter or direct vendor, read unknown, and a list reads the saved copy at most once until that fetch succeeds. It is computed on every list and never stored, so a catalog refresh changes the review but never the pinned prices, provenance, or limits. To take a new catalog price, edit the profile and save it.
How unknown prices are charged. The gateway and the agent's own cost report use the same concrete prices, resolved when the runner sends the revision to the gateway and when it starts an agent:
| Price | When unknown |
|---|---|
| Cache read | The input price of the same level |
| Cache write, OpenRouter and Custom | Twice the input price of the same level |
| Cache write, OpenAI, Anthropic, Gemini, DeepSeek | The input price of the same level |
Whether a provider bills cache writes is the billsCacheWrites field of the provider registry. These defaults can only over-charge; enter the real price to make estimates exact. The profile list shows the prices as saved, not the resolved defaults. An agent's self-reported cost uses the base prices; the gateway applies tiers.
Static profiles from the runner configuration are not tied to a registry provider: their unknown cache prices, read and write alike, cost the input price, which is how the gateway charges a static profile file.
The profile wizard
Add profile opens a five-step wizard. Back returns to an earlier step and keeps what you entered, except an API key you have not sent yet: paste it again. Choosing a different provider clears the chosen model and any price that came with it. A failed request keeps every field so you can retry, and the dialog stays open while a key, connection check, or save is in flight.
- Provider: pick the provider. The list and each provider's API modes come from
GET /api/v1/model-providers. - Connect: choose a saved key for that provider or add a new one. A new key is sent once, write-only, and the field is cleared as soon as it is stored or the dialog closes; the wizard never shows it or keeps it anywhere else.
- Model: search the model catalog with the combobox (arrow keys move through results, Enter selects, Escape closes). Each result shows its availability: listed for your key, or public catalog only. The catalog's freshness is shown below the search, so a stale or unavailable catalog is visible rather than silent. If the model is not listed, use the custom model ID option. Custom providers have no catalog, so this step is a plain Model ID field. Typing after you chose a model clears the choice until you pick again.
- Review: the selected model fills the display name and prices. Each price shows where it came from: the catalog source with its confidence and retrieval time, a price you typed, or, when editing or duplicating, the price saved on the profile. A custom model ID never inherits the previous model's price. A price no source published stays Unknown: enter it yourself, and enter
0only for a model that is really free. A catalog price you keep unedited is saved whole, cache prices and long-prompt tiers included, with acatalogPriceRefto its catalog model. A cache price the catalog does not publish is sent asnulland flat pricing as an empty tier list, so the save replaces every saved price rather than keeping an old cache price or tier. Once you edit a price, only the input and output prices you typed are sent, as your override. Saved as tells you which before you save: Catalog price, Custom price (override), or, for an edit that keeps the saved input and output prices for the same model and key, Saved price (unchanged) (the saved prices and their provenance are kept). Choose a permission preset here. Advanced holds the profile ID (derived from the model and made unique among your profiles), the API mode, the custom upstream URL (Custom only), per-run limits, prices, and the model ID. - Verify and save: run a connection check against the unsaved settings (the tool-call check is optional), then save.
Permission presets cover the 10 safe operations:
| Preset | Operations |
|---|---|
| Read only | files_list, files_read, grep_search, symbols_search, symbols_references |
| Code editing | Read only plus files_write, files_apply_patch, files_move, files_mkdir |
| Custom | Any combination you choose |
Before saving, the wizard shows the full permission change: operations added, removed, and kept compared with the saved revision (or with nothing, for a new or duplicated profile).
Saving is gated. The wizard refuses to save until:
- the connection for the current key, model, API mode, and upstream is verified, or you tick Save without a verified connection. A model that came only from the public catalog is unconfirmed for your key, so it always needs one or the other. Both apply only to the route they were made for: changing the key, model, API mode, or upstream asks again;
- you tick the acknowledgement for any newly allowed operation that changes files (
files_write,files_apply_patch,files_delete,files_move,files_mkdir). No preset or edit turns these on without that tick.
Edit and Duplicate open the wizard at the review step with the existing values. An edit keeps the profile ID and counts as verified only when the route is unchanged and the saved revision was verified. A duplicate gets a new unique ID and starts with no operations inherited as already approved.
A connection check run inside the wizard is kept on the revision the save creates, so a just-checked profile does not need a second paid check. The runner holds the draft result itself and gives the browser only an opaque, single-use verificationRef that expires after a short time (DRAFT_VERIFICATION_TTL_MS in draft-verification-store.ts); the wizard sends it with the save. The runner keeps the result only when the save is by the same principal and uses exactly the checked credential, key version, model, API mode, and resolved upstream. Otherwise the save still succeeds and the profile shows Not verified until you run Verify from the list. A runner restart also forgets pending draft results.
The profile list
Each profile shows its provider and model, verification status and when it was checked, the key it uses, its prices, and its per-run cost and token limits. Actions are Verify (checks the saved profile), Edit, Duplicate, Disable or Activate, and Delete.
A profile created by an agent over MCP (model_profile_create) is always saved Disabled. It routes no subagent until you review it here and choose Activate. A new profile has nothing to compare with, so only a Custom URL the key has not used yet stops an MCP create: the limits, prices, and proxy operations are whatever the agent chose. Activate approves that whole revision, so check them before you activate it. An MCP update that would move the profile to another key, raise spend, or widen permissions is refused with OWNER_APPROVAL_REQUIRED; make that change here instead. See API and MCP examples for the MCP tools.
The Price per 1M (in / out) column shows the revision's pinned prices as saved:
- the input and output prices, then cache read and cache write, then one line per long-prompt tier (From N prompt tokens: with that tier's prices);
- a price the revision leaves unknown reads Unknown (charged conservatively), never
$0, because the gateway charges it at the conservative default in How unknown prices are charged; - where the prices came from: a Catalog price or Custom price (override) badge, then Source: (OpenRouter, provider, or entered by you), confidence: (
exact,estimate, orunknown), and retrieved with the catalog's retrieval time when it has one.
When the repricing review reads repriced, a warning appears under the price: Catalog price changed by N% since saved, where N is the largest change the review measured (Catalog price changed since saved when no percentage applies, for example a new tier). For a profile whose price is your override, the warning reads Differs from catalog instead, because the difference is your choice. Either way, Review catalog price opens the edit form with the catalog's current price filled in; nothing is saved until you choose Save changes. Saving it unedited replaces every saved price, cache prices and tiers included, and the runner records it as the catalog's price when it still matches the runner's cached catalog price (otherwise it is saved as your override). When the catalog has no current price for the model, the form says The catalog has no current price for this model and keeps the saved price; when the catalog cannot be read, the form shows the error and keeps the saved price.
When the review reads unknown, the list says No catalog price to compare with. This note does not mean the saved price is wrong: the runner may not have loaded the catalog yet (for example just after a restart), or the catalog may not list the model. Profiles of a provider without a catalog, such as Custom, never show it. For a repriced or unknown review whose catalog has a retrieval time, the list adds Compared with the catalog from that time, so you can tell how old the comparison is. When the catalog also prices the model by conditions a saved price cannot express, the list adds The catalog also prices this model by conditions a saved price cannot express, so the charge can differ from this estimate.
Gateway status
The badge beside Model profiles reads GET /api/v1/agent-model-config-status (schema ModelConfigStatusSchema in model-profile-schemas.ts):
- Gateway synchronized (
gatewaySynced: true) means the Model Gateway answered and the last snapshot it applied matches the runner's current profile and credential snapshot: the digest it reports equals the digest of the snapshot the runner would send now. The gateway merges each snapshot into what it already holds, so profiles and credentials removed since an earlier snapshot can remain loaded there; the badge does not claim otherwise. An unreachable gateway, or one whose repair push failed, shows Gateway sync pending. - Last successful sync (
lastSyncTime) is when the runner last pushed a snapshot successfully: after a dashboard change, at runner startup, before an agent launch, or to repair the gateway. It is kept in memory, so it reads Never since the runner started (null) until the restarted runner pushes again. The badge can then still read Gateway synchronized, because the gateway kept the snapshot the previous runner sent. - The error line (
error) explains an unreachable gateway or a failed last push. A later successful push clears it, and it is not shown while the gateway's last applied snapshot matches.
When the digests differ, reading the status pushes the current snapshot once and reports the result. This repairs a gateway that restarted, missed a failed push, or holds a snapshot that changed without a push, such as when an agent pinned to an older profile revision ends. The agent manager makes the same comparison before it launches an agent on a dashboard-managed profile, and refuses the launch as retryable when the gateway cannot be brought in sync.
The runner serializes every snapshot push, and it builds each snapshot just before sending it. A push therefore always carries the latest saved state, such as a key rotated a moment earlier, and a slower push can never replace a newer snapshot with an older one. Requests that arrive while a push is already waiting share that push, so a status read or an agent launch waits for at most the push in progress and one more.
Spend policies
A spend policy caps the estimated model spend of a UTC day or month. A policy without a profile covers every subagent run of your account; a profile policy covers the runs of one saved profile. Each scope has at most one daily and one monthly policy. A static profile from the runner configuration cannot carry its own policy, so its runs count only against the account policies.
The figures are estimates, never provider billing:
- When a spawn is admitted, its whole
maxCostMicroscap is reserved in the window of that moment, and the whole run counts in that day and month even if it ends after midnight UTC. Spend near a window boundary can therefore exceed one window's limit. - The cap stays reserved until the run ends and its gateway lease settles. The run then counts at the cost the model gateway charged.
- A run whose gateway accounting was lost, for example to a gateway restart, stays at its full cap and is reported as unreconciled. It never shrinks back to a smaller figure.
A spawn whose cap does not fit in a policy's remaining headroom is refused with LIMIT_EXCEEDED and creates no agent. A policy never changes a run's own cost cap, which the model gateway still enforces.
A profile policy, and the spend counted in its windows, belong to the profile id, not to one profile record. Deleting a profile keeps its policies and its spend, and a profile created again with the same id inherits both. Delete the policies yourself if the new profile should start without them.
The Spend and limits panel
The Spend and limits panel on the Models & Budgets page reads GET /dashboard/api/v1/model-spend. Every amount in it carries an Estimate tag, and the help note under the heading says that a run's cap is reserved until the run ends. Each row is one window, Today (UTC) or This month (UTC), for All runs (your account) or for one profile, shown as its display name and id. A profile that was deleted but still has a policy or spend reads (profile deleted).
| Column | Shows |
|---|---|
| Settled | Runs settled at the cost the model gateway charged |
| Reserved | Open runs, each at its full cap |
| Unreconciled | Runs whose gateway accounting was lost, each at its full cap |
| Total | The three added together; this is what a limit is compared with |
| Limit | The policy's limit and warn at N%, or None |
| State | OK, Warning (from the warn percentage), Exceeded (at the limit), or No limit |
The state is written as a word; its green, amber, or red pill only repeats it.
Adding a limit. Use Set limit on a row, or the Add a spend limit form under the table. Choose Applies to (All runs (account) or a saved profile), Period (Daily (UTC day) or Monthly (UTC month)), Limit (USD), and Warn at (% of limit) (80 unless you change it), then Add limit. The limit is typed in US dollars with at most six decimal places, above $0 and at most $1,000,000; a leading $, surrounding spaces, leading zeros, and a bare fraction such as .5 are accepted. The browser converts it to whole micro-dollars without rounding, and refuses a negative amount or a seventh decimal place before anything is sent. A scope and period that already has a limit is refused with the server's message; the panel reloads so you can see and edit that limit instead.
Editing a limit. Edit limit opens the Edit spend limit form with the stored values. A limit keeps its scope and period, so only Limit (USD) and Warn at (% of limit) can change; to move it, delete it and add a new one. Save limit sends the limit with the version you opened. If someone changed or deleted it meanwhile, the panel shows the server's message, reloads, and fills the form with the current values for you to review and save again. If the limit was deleted and a new one added for the same scope and period, the form opens that limit instead; if none exists any more, the form turns into Add a spend limit with your typed values kept.
Deleting a limit. Delete limit asks for confirmation, naming the period and the scope as the Applies to column does (All runs, the profile's display name followed by its ID in parentheses, or the ID followed by (profile deleted)), then removes the limit. Recorded spend is kept. A limit that changed or was already deleted is refused with the server's message and the panel reloads.
Spend routes
Over MCP, model_spend_summary reads the same estimated spend and the policies you set here, but no MCP tool creates, changes, or deletes a policy. Scripts can use the dashboard routes the panel uses. Each needs a signed-in dashboard session. A change also needs the session cookie, an x-csrf-token header set to the csrfToken field of the GET /dashboard/api/v1/session response, Content-Type: application/json, and the dashboard's own Origin; the dashboard page sends all of these itself.
GET /dashboard/api/v1/model-spendreturns the summary and the policies. For the current UTC day and month, the summary lists your account and every saved profile that has a policy or spend this month. Each window reportsestimatedSettledMicros,estimatedReservedMicros(open runs at their cap), andestimatedUnreconciledMicros, its start and end, its policy, and athresholdStateofok,warning(from the policy'swarnPercent), orexceeded(at the limit).GET /dashboard/api/v1/model-spend/policiesreturns only the policies.PUT /dashboard/api/v1/model-spend/policiescreates or updates a policy:{ "policyId"?, "profileId"?, "period": "day" | "month", "limitMicros", "warnPercent"?, "expectedGeneration"? }.- To create one, send neither
policyIdnorexpectedGeneration. A new profile policy must name one of your saved profiles, and a scope and period that already has a policy is refused as a conflict.warnPercentdefaults to 80. - To update one, send its
policyIdand currentgenerationasexpectedGeneration, with the scope and period it already has; a policy cannot move to another scope or period. A changed generation, or a policy that was deleted and created again for the same scope and period, is refused as a conflict, so reload the policy and retry. A policy that was deleted and not replaced is refused as not found; create it again. An update that omitswarnPercentkeeps the current value.
- To create one, send neither
DELETE /dashboard/api/v1/model-spend/policies/<id>with{ "expectedGeneration" }deletes a policy. A policy whose profile was deleted is still listed so you can remove it.
Every policy change is recorded in the audit log with its id, period, and limit. The limits on values are in packages/contracts/src/model-spend-schemas.ts.
Where this page lives now
The page is labelled Models & Budgets in the navigation and still lives at /dashboard/models, so existing links keep working. It follows the shared resource-page layout: the page heading and help come from the dashboard's page registry, the single accented action (Add profile) and its secondary (Add credential) sit in the header action slot, and both open dialogs rather than permanent forms under the list. Identifiers are copy affordances rather than labels.