server/routers/aiBudget/README.md
Public/OSS CRUD entity (server/routers/aiBudget/, not enterprise-gated).
Table: aiBudgets in server/db/{pg,sqlite}/schema/schema.ts, type AiBudget.
A row is a spend/usage cap of amount unit (usd | tokens) per period
(hourly | daily | weekly | monthly | yearly | lifetime), with
enforcement (hard | soft) and an enabled flag.
Every budget belongs to an org (orgId, required) and is optionally further
scoped to exactly one of:
providerId → an aiProviders rowmodelId → an aiModels rowresourceId → a resources rowsiteResourceId → a siteResources rowroleId → a roles rowIf none of those five are set, the budget is org-wide. Setting more than
one at once is rejected by validation.ts's refineBudgetScopeFields
(400, "Only one of providerId, modelId, resourceId, siteResourceId, or
roleId may be set on a budget").
A given scope (one specific provider, or model, or resource, or site
resource, or role, or "org-wide") may have multiple budgets, but at most
one per (unit, period) combination — e.g. one weekly/usd budget and
one hourly/usd budget can coexist on the same provider, but two
weekly/usd budgets cannot. This is enforced at two levels:
unique constraints in both schema files —
ai_budget_provider_uniq (providerId, unit, period),
ai_budget_model_uniq (modelId, unit, period),
ai_budget_resource_uniq (resourceId, unit, period),
ai_budget_site_resource_uniq (siteResourceId, unit, period),
ai_budget_role_uniq (roleId, unit, period). (NULL scope columns never
collide under a plain unique index, so this does not cover the org-wide
case — see next bullet.)createAiBudget/updateAiBudget both run an explicit pre-check
query keyed on (scopeCondition, unit, period) before insert/update,
where scopeCondition is eq(<scopeColumn>, id) for whichever scope
field is set, or — when none is set — orgId = X AND all five scope
columns IS NULL, so org-wide budgets get the same one-per-(unit, period) guarantee even though the DB constraint can't express it.
Violating this returns 409 with
`A ${period} ${unit} budget already exists for this scope`.Because only one row can ever exist for a given (scope, unit, period),
there is no separate check needed to prevent a hard and a soft budget
from coexisting on the same (scope, unit, period) — the conflict check
above already blocks the second row regardless of its enforcement value.
On updateAiBudget, the conflict/ownership checks are run against the
merged next-state (existing row's scope/unit/period overlaid with
whatever the request body changes), not just the fields present in the
body — so e.g. changing only unit on a budget that already has
providerId set re-validates against that provider's other budgets at the
new unit.
providerId/modelId/resourceId/siteResourceId/roleId are validated
to belong to the same orgId as the budget (modelId via an
aiModels ⋈ aiProviders join, since aiModels has no orgId column
directly). A mismatch returns 404, not 403 — this matches how the
sibling aiProvider/aiModel routers report cross-org references.
All under server/routers/external.ts, registered right after the
aiProvider/aiModel block. PUT = create, POST = update (repo
convention, not standard REST).
| Method | Path | Middleware | Action | Handler |
|---|---|---|---|---|
| PUT | /org/:orgId/ai-budget | verifyOrgAccess | createAiBudget | createAiBudget |
| GET | /org/:orgId/ai-budgets | verifyOrgAccess | listAiBudgets | listAiBudgets (paginated) |
| GET | /ai-budget/:budgetId | verifyAiBudgetAccess | getAiBudget | getAiBudget |
| POST | /ai-budget/:budgetId | verifyAiBudgetAccess | updateAiBudget | updateAiBudget |
| DELETE | /ai-budget/:budgetId | verifyAiBudgetAccess | deleteAiBudget | deleteAiBudget |
| GET | /ai-provider/:providerId/ai-budgets | verifyAiProviderAccess | listAiBudgets | listAiBudgetsForProvider |
| GET | /ai-model/:modelId/ai-budgets | verifyAiModelAccess | listAiBudgets | listAiBudgetsForModel |
| GET | /resource/:resourceId/ai-budgets | verifyResourceAccess | listAiBudgets | listAiBudgetsForResource |
| GET | /site-resource/:siteResourceId/ai-budgets | verifySiteResourceAccess | listAiBudgets | listAiBudgetsForSiteResource |
| GET | /role/:roleId/ai-budgets | verifyRoleAccess | listAiBudgets | listAiBudgetsForRole |
The five scope-filtered GET .../ai-budgets routes intentionally reuse the
single ActionsEnum.listAiBudgets action rather than getting one action
each — access control is already fully handled by the entity-specific
middleware (a user who can see the provider/resource/etc. can see its
budgets), so per-scope actions would just be enum bloat. They also skip
pagination (unlike the org-wide list) since a single entity realistically
has only a handful of (unit, period) budgets — response shape is a flat
{ budgets: AiBudget[] } (ListAiBudgetsByScopeResponse), not
PaginatedResponse.
verifyAiBudgetAccess (server/middlewares/verifyAiBudgetAccess.ts) loads
the budget by budgetId, resolves its orgId directly off the row (no
join needed, unlike verifyAiModelAccess), and stashes it on
req.aiBudget so getAiBudget/updateAiBudget can skip a re-fetch.
providerId?, modelId?, resourceId?, siteResourceId?,
roleId? (all number, mutually exclusive), amount (positive
number, required), unit (required), period (default "monthly"),
enforcement (default "hard"), enabled? (default true).nullable().optional() so a client can explicitly send null to clear
a scope (turning a scoped budget into an org-wide one).budget: AiBudget (or
budgets: AiBudget[] + pagination for the org-wide list). No public/
private mapper exists for AiBudget — unlike AiProvider, there's no
secret field to strip, so the raw DB row is returned as-is.Schema changes here (composite unique constraints) were made directly in
schema.ts without hand-writing a server/migrations/*.sql file — this
repo's CI (.github/workflows/test.yml) runs drizzle-kit generate
against schema.ts fresh, and other recent schema-only commits (e.g. "Remove
budget periods") follow the same pattern of not committing a matching
migration by hand.