Per-provider daily budget and provider health

On 2026-10-01 the platform's OpenRouter key reached OpenRouter's own daily key limit, and every model call on it failed with HTTP 403 … Key limit exceeded (daily limit): chat, the GLM-5.3 PR reviewer, and the memex search embeddings. Nobody noticed. The failure showed up only inside individual thread cells. MeshWeaver had no page that showed a provider's limit, its spend today or its health.

This page describes the three things that fix that:

  1. a daily limit per provider in MeshWeaver, enforced before a round calls the provider, from spend that MeshWeaver itself records;
  2. a health record per provider key, written when the provider refuses the key and when the key works again;
  3. a section on the Providers page and on each provider page that shows limit, today's spend, remaining amount, health and last success.

Code: src/MeshWeaver.AI/ProviderBudget/ (ProviderBudgetRule, ProviderHealthRule, ProviderBudgetLedger, ProviderBudgetGuard, ProviderBudgetView) and the call sites in ThreadExecution.

The limit

ModelProviderConfiguration.DailyLimit, a decimal? on the provider node:

Value Meaning
null / blank no limit — nothing is enforced; spend is still recorded and shown
300 $300.00 per UTC day on this key — a guard rail, not a hard cap (see Overshoot)

Which key a round spends: the key holder

A limit belongs to the node that holds the key. Provider/OpenRouterEU has no key of its own. Its CredentialFrom names Provider/OpenRouter, so it is the same account reached through another endpoint. Every round through either provider spends the one budget on Provider/OpenRouter. ProviderBudgetRule.KeyHolderOf follows exactly the hop that ChatClientCredentialResolver follows:

A limit set on a borrower is not read. Only root-catalog providers are budgeted. A user's own BYOK key ({user}/_Memex/{provider}) is not the platform's key and is never metered here.

What counts as today's spend

One record per round, at Admin/ProviderBudget/{holder}/{yyyy-MM-dd}/{roundId} (ProviderSpendCharge). Each record is priced once, when the round finishes, with the same rate the credit meter uses (ModelCreditGuard.ObserveRate → the model node's authored price, else the built-in table). This is one cost model with three readers. Today's spend is the fold of the day's records (ProviderBudgetRule.Fold).

Why a third record, next to the two that already exist:

Existing record Why it cannot answer "spent on this key today"
TokenUsage ({thread}/_Usage/{model}) a cumulative per-(thread, model) total with no time dimension; a thread that ran yesterday and continues today carries both days in one number
ModelCreditCharge (Admin/ModelCredit/…) per round and timestamped, but written only for rounds billed to a subscriber's plan; a platform-internal round (the PR reviewer, a scheduled automation) spends the same key and appears nowhere in it

What is counted: every thread round (chat, agents, delegations, the PR reviewer) on the MeshWeaver harness whose model sits under a root-catalog provider, whatever subscriber or plan the round belongs to. Errored and cancelled rounds count what they reported, or a character-based estimate when the provider reported nothing (marked IsEstimated; the estimate errs high, which is the safe direction for a budget).

What is NOT counted, and is not faked:

Enforcement

ProviderBudgetGuard.Check runs in ThreadExecution next to the credit gate, at the last point before the round calls a provider (the model that will answer is known, nothing has been sent). It is reactive end to end and never faults. The verdict (ProviderBudgetRule.Decide):

Outcome When Round
NoLimit the holder has no DailyLimit runs
Within spent today < limit runs
Reached spent today ≥ limit refused
Undetermined limit set, but the records could not be read within the bound, or a row is in a foreign currency runs, and a warning is logged

A refused round ends as an Error cell whose text names the provider, the spend, the limit and the reset (key chat.providerDailyBudgetReached):

OpenRouter daily budget reached: $300.00 of $300.00 spent today; resets 00:00 UTC. This round did not run — nothing was sent to the provider.

🚨 An undetermined spend does not block. The budget is a guard rail in front of the provider's own limit. It is not the only bound on a key. A broken counter (an unreadable Admin partition, a slow read, an operator who prices one model in EUR) must not take every model on the platform key down, because that is exactly the outage this feature exists to prevent. The gap is logged as [ProviderBudget] today's spend on … is UNDETERMINED … the round RUNS.

🚨 There is no fallback. A refused round is not silently re-routed to another provider, because that would spend a key nobody chose for it.

Mid-round crossings complete. As with the credit meter, a round that is already streaming has already spent its tokens. The round that crosses the limit finishes and records its spend. The next round is refused.

Overshoot — a guard rail, not a hard cap. The check reads today's recorded spend and reserves nothing, and a round records its spend when it finishes. Rounds that start together (parallel agent delegations, several users at once) can all read "within" and all run, so the day can end above the limit by what those in-flight rounds cost. That is accepted deliberately: reserving an estimate up front would refuse rounds on a guess and still be wrong in both directions. For a hard ceiling keep the provider's own key limit (e.g. OpenRouter's per-key credit limit) as the outer backstop, a little above this one.

Provider health

ProviderHealth at Admin/ProviderBudget/{holder}/_Health, one node per key holder:

Where it is shown

Columns: provider, daily limit (or no limit), today's spend, remaining, health (OK / failing since … : last refusal), last success. A reached budget or a failing key is marked 🔴 in the cell and in a line above the grid (a grid cell cannot be coloured). Timestamps are rendered in the viewer's zone (DisplayTimeExtensions.ToDisplayTime with the zone captured on the render turn).

🚨 Platform admins only. The records live under Admin, and the provider configurations are read as System, so the section sits behind the same positive-confirmation gate as the AI usage & cost tab (AiUsageCostSettingsTab.ConfirmedAdmin). A non-admin, a slow evaluator or a faulted one sees nothing, and no System read is issued.

After deploying

  1. Set DailyLimit: 300 on Provider/OpenRouter on every instance that carries the platform OpenRouter key (through the provider page; the root catalog is admin-writable).
  2. The budget gate and the health writes run in the thread hubs' ThreadExecution, which binds the AI module once per activation. Running thread hubs pick up the new engine only when they are recycled or when the pod rolls. The provider pages (Provider/OpenRouter, Provider/OpenRouterEU) and Provider (the Providers catalog area) render from the per-node hub configuration and need a recycle to show the new section.

Follow-ups