Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,12 @@ Self-hosting gives you control of the Facility services, database, and workspace
Code and Codex communicate with the model service you configure for the engine. Choose providers
and credentials that fit your infrastructure requirements.

Project budgets are checked before new provider calls. Usage is recorded afterwards, so an
in-flight call can take spending beyond the monthly limit. Retained workspaces also need an
Project budgets are checked before new provider calls. An interrupted worker's usage is
recovered from retained per-turn journals where possible. Missing
or incomplete usage pauses new model calls while budget enforcement is enabled; see the
[budget FAQ](apps/docs/docs/faq.md#does-a-project-budget-delete-or-stop-a-workspace).
Usage is recorded afterwards, so an in-flight call can take spending beyond the monthly limit.
Retained workspaces also need an
explicit storage and deletion policy.

The [hardening guide](apps/docs/docs/reference/hardening.md) covers isolation, credentials,
Expand Down
15 changes: 15 additions & 0 deletions apps/docs/docs/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,21 @@ afterwards; later turns are blocked once the monthly limit is reached. The workt
and engine sessions remain. Unknown model pricing is rejected while budget enforcement is enabled,
and unavailable workspace pricing is not reported as zero.

If a worker dies, recovery reads the turn's retained usage journal. Final counters are charged
once; partial counters are a lower bound. Missing, unreadable, or incomplete usage is marked
unpriced and the budget state becomes `unconfirmed`: new agent and title-generation model calls
are blocked while enforcement is enabled. Raising the limit, resolving attention, or starting a
new month does not confirm the bill. Recovery retries retained journals without waking compute
or rerunning the agent. A finalized journal clears the block after reconciliation.
The same recovery pass marks missing bills on worker-interrupted turns failed by older
Facility versions as unconfirmed; it cannot invent usage that was never retained.

When no final journal can be recovered, inspect the provider bill and retained session files;
the true charge may exceed the recorded lower bound. There is currently no manual billing
reconciliation endpoint. An authorized budget administrator can explicitly disable enforcement
to continue, accepting that spend remains incomplete. Deleting the workspace loses evidence
and does not clear the accounting block.

To use a newly supported model, update the agent's `model` field and ensure the
deployed Facility version contains its price-book entry. See
[cost and budget API behavior](reference/api.md) for the supported additions and
Expand Down
7 changes: 7 additions & 0 deletions apps/docs/docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,13 @@ verified on 2026-09-29. These entries use standard global API pricing and
5-minute cache writes, not fast mode, batch, regional premiums, or 1-hour cache
writes. A valid engine-reported cost takes precedence over this fallback.
An enabled budget still blocks an unpriced model or a project over its limit.
Interrupted turns with incomplete usage return `state: "unconfirmed"` in budget responses.
Their usage rows have `priced: false`, `source: "unpriced"`, and a nullable cost or a measured
lower bound. New agent and title-generation model calls are blocked until complete retained
usage is reconciled or an authorized administrator explicitly disables budget enforcement.
Raising the limit, resolving attention, and a new calendar month do not clear uncertainty.
Recovered usage is counted when it is settled, as with live-worker accounting; reconciliation does not double-charge
already settled rows. The periodic worker retries available journals without waking compute.
Updating the catalog does not reprice persisted turns or change agent defaults.

## Authentication and authorization
Expand Down
14 changes: 10 additions & 4 deletions apps/web/app/(app)/projects/[projectId]/insights/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,11 @@ export default async function InsightsPage({ params }: { params: Promise<{ proje
<PillTag>{budget.data.state.replaceAll("_", " ")}</PillTag>
</div>
<p className="text-[12px] text-(--dim)">
{money(budget.data.spent_cents)} spent this month. New turns are blocked once the limit is
reached; a provider call already in progress is allowed to finish and is accounted
afterwards.
{budget.data.state === "unconfirmed" ? "At least " : ""}
{money(budget.data.spent_cents)} recorded this month. New turns are blocked once the limit
is reached; a provider call already in progress is allowed to finish and is accounted
afterwards. If an interrupted turn's full usage cannot be recovered, spending is shown as
a lower bound and new model calls are blocked while the budget is enabled.
</p>
<BudgetForm projectId={projectId} budget={budget.data} />
</section>
Expand Down Expand Up @@ -167,6 +169,7 @@ function UsageTable({
name: string;
turns: number;
costCents: number;
unpricedTurns?: number;
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
Expand All @@ -187,7 +190,10 @@ function UsageTable({
>
<span className="truncate font-mono text-(--ink)">{row.name}</span>
<span className="text-(--dim)">{row.turns} turns</span>
<span className="font-mono text-(--mut)">{money(row.costCents)}</span>
<span className="font-mono text-(--mut)">
{(row.unpricedTurns ?? 0) > 0 ? "≥ " : ""}
{money(row.costCents)}
</span>
</div>
))
)}
Expand Down
25 changes: 17 additions & 8 deletions apps/web/lib/overview-presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,20 +223,27 @@ export function attentionSignals(
});
}
const budget = overview.spend.budget;
if (budget.available && (budget.state === "exceeded" || budget.state === "warning")) {
if (
budget.available &&
(budget.state === "exceeded" || budget.state === "warning" || budget.state === "unconfirmed")
) {
entries.push({
key: "budget",
tone: budget.state === "exceeded" ? "bad" : "human",
tone: budget.state === "warning" ? "human" : "bad",
title:
budget.state === "exceeded"
? "Monthly budget exhausted: new agent turns are blocked"
: "Monthly budget warning",
budget.state === "unconfirmed"
? "Spending unconfirmed: new model calls are blocked"
: budget.state === "exceeded"
? "Monthly budget exhausted: new agent turns are blocked"
: "Monthly budget warning",
storyId: null,
storyTitle: null,
summary: `${money(budget.spentCents)} of ${money(budget.monthlyLimitCents ?? 0)} used this month.${
budget.state === "exceeded"
? " Raise the limit or wait for the next month to continue."
: " New turns are blocked once the limit is reached."
budget.state === "unconfirmed"
? " Interrupted usage could not be fully recovered. Raising the limit does not clear this block."
: budget.state === "exceeded"
? " Raise the limit or wait for the next month to continue."
: " New turns are blocked once the limit is reached."
}`,
at: null,
action: {
Expand Down Expand Up @@ -335,6 +342,8 @@ export function budgetReading(budget: ProjectOverview["spend"]["budget"]) {
return { label: "Budget disabled", tone: "machine" as Tone };
case "exceeded":
return { label: "Budget exhausted", tone: "bad" as Tone };
case "unconfirmed":
return { label: "Spending unconfirmed", tone: "bad" as Tone };
case "warning":
return { label: "Budget warning", tone: "human" as Tone };
default:
Expand Down
20 changes: 18 additions & 2 deletions apps/web/test/insights-page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import InsightsPage from "../app/(app)/projects/[projectId]/insights/page";

const mocks = vi.hoisted(() => ({ denied: false, measured: 1, failed: 2 }));
const mocks = vi.hoisted(() => ({
denied: false,
measured: 1,
failed: 2,
budgetUnconfirmed: false,
}));
vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh() {} }) }));
vi.mock("../components/insights/budget-form", () => ({ BudgetForm: () => null }));
vi.mock("../lib/api", () => ({
Expand Down Expand Up @@ -51,7 +56,10 @@ vi.mock("../lib/api", () => ({
recentAudit: [],
},
},
projectBudget: async () => ({ ok: true, data: { state: "not_configured", spent_cents: 0 } }),
projectBudget: async () => ({
ok: true,
data: { state: mocks.budgetUnconfirmed ? "unconfirmed" : "not_configured", spent_cents: 0 },
}),
},
}));

Expand All @@ -62,6 +70,14 @@ async function page() {
}

describe("Insights spend rendering", () => {
it("shows interrupted budget spending as a lower bound and explains the model-call block", async () => {
mocks.budgetUnconfirmed = true;
const html = await page();
expect(html).toContain("unconfirmed");
expect(html).toContain("At least");
expect(html).toContain("new model calls are blocked while the budget is enabled");
mocks.budgetUnconfirmed = false;
});
it("shows partial cost and the missing usage in the actual page", async () => {
mocks.denied = false;
mocks.measured = 1;
Expand Down
9 changes: 7 additions & 2 deletions apps/web/test/overview-presentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,12 +347,17 @@ describe("spend readings", () => {
expect(budgetReading({ available: false, reason: "permission" }).label).toBe(
"Not visible for your role",
);
const budget = (state: "not_configured" | "disabled" | "ok" | "warning" | "exceeded") =>
({ available: true, state }) as ProjectOverview["spend"]["budget"];
const budget = (
state: "not_configured" | "disabled" | "ok" | "warning" | "exceeded" | "unconfirmed",
) => ({ available: true, state }) as ProjectOverview["spend"]["budget"];
expect(budgetReading(budget("not_configured")).label).toBe("No monthly budget");
expect(budgetReading(budget("ok"))).toEqual({ label: "Within budget", tone: "ok" });
expect(budgetReading(budget("warning")).tone).toBe("human");
expect(budgetReading(budget("exceeded")).tone).toBe("bad");
expect(budgetReading(budget("unconfirmed"))).toEqual({
label: "Spending unconfirmed",
tone: "bad",
});
});
it("reports recorded workspace states without claiming live inspection", () => {
expect(
Expand Down
12 changes: 8 additions & 4 deletions packages/sdk/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -6074,7 +6074,8 @@
"disabled",
"ok",
"warning",
"exceeded"
"exceeded",
"unconfirmed"
]
},
"enforcement": {
Expand Down Expand Up @@ -6299,7 +6300,8 @@
"disabled",
"ok",
"warning",
"exceeded"
"exceeded",
"unconfirmed"
]
},
"enforcement": {
Expand Down Expand Up @@ -6606,7 +6608,8 @@
"disabled",
"ok",
"warning",
"exceeded"
"exceeded",
"unconfirmed"
]
},
"enforcement": {
Expand Down Expand Up @@ -8999,7 +9002,8 @@
"disabled",
"ok",
"warning",
"exceeded"
"exceeded",
"unconfirmed"
]
},
"enabled": {
Expand Down
8 changes: 4 additions & 4 deletions packages/sdk/src/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4878,7 +4878,7 @@ export interface operations {
remaining_cents: number | null;
percent_used: number | null;
/** @enum {string} */
state: "not_configured" | "disabled" | "ok" | "warning" | "exceeded";
state: "not_configured" | "disabled" | "ok" | "warning" | "exceeded" | "unconfirmed";
/** @enum {string} */
enforcement: "block_new_turns";
};
Expand Down Expand Up @@ -5001,7 +5001,7 @@ export interface operations {
remaining_cents: number | null;
percent_used: number | null;
/** @enum {string} */
state: "not_configured" | "disabled" | "ok" | "warning" | "exceeded";
state: "not_configured" | "disabled" | "ok" | "warning" | "exceeded" | "unconfirmed";
/** @enum {string} */
enforcement: "block_new_turns";
};
Expand Down Expand Up @@ -5142,7 +5142,7 @@ export interface operations {
remainingCents: number | null;
percentUsed: number | null;
/** @enum {string} */
state: "not_configured" | "disabled" | "ok" | "warning" | "exceeded";
state: "not_configured" | "disabled" | "ok" | "warning" | "exceeded" | "unconfirmed";
/** @enum {string} */
enforcement: "block_new_turns";
};
Expand Down Expand Up @@ -5952,7 +5952,7 @@ export interface operations {
/** @enum {boolean} */
available: true;
/** @enum {string} */
state: "not_configured" | "disabled" | "ok" | "warning" | "exceeded";
state: "not_configured" | "disabled" | "ok" | "warning" | "exceeded" | "unconfirmed";
enabled: boolean;
monthlyLimitCents: number | null;
warningPercent: number | null;
Expand Down
Loading