Skip to content
Merged
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
4 changes: 4 additions & 0 deletions apps/agent/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import { useVaultEntities } from "@brainstorm/react-yjs";
import { type OpenCapableRuntime, openEntity } from "@brainstorm/sdk";
import {
AI_BUDGET_EXHAUSTED_ERROR_KIND,
type AgentLoopResult,
type AgentLoopStep,
AgentStopReason,
Expand Down Expand Up @@ -1104,6 +1105,9 @@ export function AgentApp(): ReactElement {
const kind = e.kind ?? e.name ?? "";
if (kind === "Unavailable") setError(unavailableMessage(conversationProvider));
else if (kind === "CapabilityDenied") setError(t("error.capability"));
// 14.8 — the shell refused the call because this app's rolling AI
// budget is exhausted (distinct from Unavailable: the model is fine).
else if (kind === AI_BUDGET_EXHAUSTED_ERROR_KIND) setError(t("error.quotaExhausted"));
else setError(`${t("error.generic")}${e.message ? ` (${e.message})` : ""}`);
} finally {
setSending(false);
Expand Down
2 changes: 2 additions & 0 deletions apps/agent/src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ export const AGENT_I18N = {
"No AI model could be reached. Pick a provider for this conversation in settings, or set one up in Settings → AI.",
"error.capability":
"The Agent app is missing AI access — restart the shell so it reinstalls with the `ai.use` capability granted.",
"error.quotaExhausted":
"The Agent app has used up its 30-day AI budget. Raise or clear the budget in Settings → AI to keep chatting.",
"error.generic": "Something went wrong generating a reply.",
"provenance.via": "via {model}",

Expand Down
6 changes: 6 additions & 0 deletions packages/sdk-types/src/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ export const GLM_PROVIDER_ID = "glm";
* provider as OpenAI under its own base URL. Cap scope `ai.provider:mistral`. */
export const MISTRAL_PROVIDER_ID = "mistral";

/** Error kind (the app-side `error.name` / wire `error.kind`) the AI broker
* returns when the calling app's rolling AI budget is exhausted (14.8).
* Distinct from `Unavailable` so apps can surface "AI budget exhausted"
* (with a path to Settings → AI) instead of a generic failure. */
export const AI_BUDGET_EXHAUSTED_ERROR_KIND = "AiBudgetExhausted";

/** Local alias for an entity id — a plain `string` so this contract leaf
* stays dependency-free and introduces no barrel cycle. */
type ConversationEntityId = string;
Expand Down
1 change: 1 addition & 0 deletions packages/sdk-types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2385,6 +2385,7 @@ export type {

// ─── Conversation / messaging (doc 55 — Agent app + Chats foundation) ───────
export {
AI_BUDGET_EXHAUSTED_ERROR_KIND,
AI_EXTRACT_FIELD_TYPES,
AI_STREAM_EVENT_KINDS,
AI_TRANSFORM_KINDS,
Expand Down
32 changes: 32 additions & 0 deletions packages/sdk/src/errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import {
AiBudgetExhausted,
CapabilityDenied,
Invalid,
NotFound,
Unavailable,
makeSdkError,
} from "./errors";

describe("makeSdkError", () => {
it("reconstructs the typed class for each known wire kind", () => {
expect(makeSdkError("CapabilityDenied", "m")).toBeInstanceOf(CapabilityDenied);
expect(makeSdkError("NotFound", "m")).toBeInstanceOf(NotFound);
expect(makeSdkError("Unavailable", "m")).toBeInstanceOf(Unavailable);
expect(makeSdkError("Invalid", "m")).toBeInstanceOf(Invalid);
});

it("14.8 — AiBudgetExhausted is a distinct, matchable class (never Unavailable)", () => {
const err = makeSdkError("AiBudgetExhausted", "budget gone");
expect(err).toBeInstanceOf(AiBudgetExhausted);
expect(err).not.toBeInstanceOf(Unavailable);
expect(err.name).toBe("AiBudgetExhausted");
expect(err.message).toBe("budget gone");
});

it("unknown kinds fall through to a generic named Error", () => {
const err = makeSdkError("Mystery", "m");
expect(err.name).toBe("Mystery");
expect(err).toBeInstanceOf(Error);
});
});
12 changes: 12 additions & 0 deletions packages/sdk/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,16 @@ export class Invalid extends BrainstormSdkError {
}
}

/** 14.8 — the calling app's rolling 30-day AI budget is exhausted (Settings →
* AI). Distinct from `Unavailable` so apps can surface "AI budget exhausted"
* instead of a generic failure; the budget resets as usage rolls out of the
* window or when the user raises/clears it. */
export class AiBudgetExhausted extends BrainstormSdkError {
constructor(message: string, detail: ErrorDetail = {}) {
super("AiBudgetExhausted", message, detail);
}
}

/**
* Reconstruct the right error subclass from a wire reply's `error` payload.
* Unknown `kind` values fall through to a generic Error so callers always
Expand All @@ -83,6 +93,8 @@ export function makeSdkError(kind: string, message: string, detail: ErrorDetail
return new Unavailable(message, detail);
case "Invalid":
return new Invalid(message, detail);
case "AiBudgetExhausted":
return new AiBudgetExhausted(message, detail);
default: {
const err = new Error(message);
err.name = kind || "Error";
Expand Down
1 change: 1 addition & 0 deletions packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
export { type Bridge, type BridgeEnvelope, type BridgeReply, newMessageId } from "./bridge";
export { encodeHandshake, decodeHandshake } from "./handshake";
export {
AiBudgetExhausted,
CapabilityDenied,
Conflict,
Invalid,
Expand Down
231 changes: 231 additions & 0 deletions packages/shell/src/main/ai/ai-quota.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { CreditEntryKind, CreditLedgerRepository } from "../billing/credit-ledger-repo";
import { DataStores } from "../storage/data-stores";
import {
AI_BUDGET_WINDOW_MS,
AI_USAGE_RETENTION_MS,
AiBudgetUnit,
AiQuotaService,
} from "./ai-quota";
import { AiUsageOutcome, type AiUsageRecord } from "./ai-usage-log";
import { AiUsageRepository } from "./ai-usage-repo";
import { CREDIT_MICROS } from "./model-rates";

const APP = "io.brainstorm.agent";

const record = (overrides: Partial<AiUsageRecord> = {}): AiUsageRecord => ({
ts: Date.now(),
appId: APP,
verb: "generate",
provider: "anthropic",
model: "claude-opus-4-8",
promptTokens: 1000,
completionTokens: 500,
totalTokens: 1500,
outcome: AiUsageOutcome.Ok,
durationMs: 200,
...overrides,
});

describe("AiQuotaService", () => {
let vaultDir: string;
let stores: DataStores;
let repo: AiUsageRepository;
let creditLedger: CreditLedgerRepository;

beforeEach(async () => {
vaultDir = await mkdtemp(join(tmpdir(), "brainstorm-ai-quota-"));
stores = new DataStores(vaultDir);
const db = await stores.open("account");
repo = new AiUsageRepository(db);
creditLedger = new CreditLedgerRepository(db);
});
afterEach(async () => {
stores.close();
await rm(vaultDir, { recursive: true, force: true });
});

function service(overrides: Partial<ConstructorParameters<typeof AiQuotaService>[0]> = {}) {
return new AiQuotaService({
getUsageRepo: async () => repo,
getBudgets: async () => ({}),
...overrides,
});
}

describe("checkBudget", () => {
it("passes with no budget configured (unlimited by policy)", async () => {
await expect(service().checkBudget(APP)).resolves.toBeUndefined();
});

it("blocks with the distinct AiBudgetExhausted error once tokens hit the ceiling", async () => {
await service().recordUsage(record({ totalTokens: 1500 }));
const quota = service({ getBudgets: async () => ({ [APP]: { maxTokens: 1000 } }) });
await expect(quota.checkBudget(APP)).rejects.toMatchObject({
name: "AiBudgetExhausted",
unit: AiBudgetUnit.Tokens,
used: 1500,
limit: 1000,
});
});

it("blocks on a credit ceiling (compared in micro units)", async () => {
// 1M prompt + 1M completion tokens on opus = $30 = 30 credits.
await service().recordUsage(
record({ promptTokens: 1_000_000, completionTokens: 1_000_000, totalTokens: 2_000_000 }),
);
const quota = service({ getBudgets: async () => ({ [APP]: { maxCredits: 30 } }) });
await expect(quota.checkBudget(APP)).rejects.toMatchObject({
name: "AiBudgetExhausted",
unit: AiBudgetUnit.Credits,
limit: 30,
});
// A higher ceiling still passes.
const roomy = service({ getBudgets: async () => ({ [APP]: { maxCredits: 31 } }) });
await expect(roomy.checkBudget(APP)).resolves.toBeUndefined();
});

it("window rollover: usage older than 30 days frees the budget", async () => {
const now = Date.now();
await service().recordUsage(record({ ts: now - AI_BUDGET_WINDOW_MS - 1000, totalTokens: 5000 }));
const quota = service({ getBudgets: async () => ({ [APP]: { maxTokens: 1000 } }) });
await expect(quota.checkBudget(APP)).resolves.toBeUndefined();
// A fresh call inside the window re-blocks.
await service().recordUsage(record({ ts: now, totalTokens: 5000 }));
await expect(quota.checkBudget(APP)).rejects.toMatchObject({ name: "AiBudgetExhausted" });
});

it("budgets do not bind other apps", async () => {
await service().recordUsage(record({ totalTokens: 99_999 }));
const quota = service({ getBudgets: async () => ({ "other.app": { maxTokens: 1 } }) });
await expect(quota.checkBudget(APP)).resolves.toBeUndefined();
});

it("exempts the shell and the reserved _shell.* namespace", async () => {
const quota = service({
getBudgets: async () => ({ shell: { maxTokens: 1 }, "_shell.ai": { maxTokens: 1 } }),
getUsageRepo: async () => {
throw new Error("must not be consulted for exempt callers");
},
});
await expect(quota.checkBudget("shell")).resolves.toBeUndefined();
await expect(quota.checkBudget("_shell.ai")).resolves.toBeUndefined();
});

it("fails closed (Unavailable) when a budget exists but the usage store is unreadable", async () => {
const quota = service({
getBudgets: async () => ({ [APP]: { maxTokens: 1000 } }),
getUsageRepo: async () => null,
});
await expect(quota.checkBudget(APP)).rejects.toMatchObject({ name: "Unavailable" });
const throwing = service({
getBudgets: async () => ({ [APP]: { maxTokens: 1000 } }),
getUsageRepo: async () => {
throw new Error("db locked");
},
});
await expect(throwing.checkBudget(APP)).rejects.toMatchObject({ name: "Unavailable" });
});

it("fails closed (Unavailable) when the budget store itself is unreadable", async () => {
const quota = service({
getBudgets: async () => {
throw new Error("settings corrupted");
},
});
await expect(quota.checkBudget(APP)).rejects.toMatchObject({ name: "Unavailable" });
});
});

describe("recordUsage", () => {
it("writes one priced accounting row per call", async () => {
await service().recordUsage(
record({ promptTokens: 1_000_000, completionTokens: 0, totalTokens: 1_000_000 }),
);
const totals = repo.totalsForApp(APP, 0);
expect(totals.calls).toBe(1);
expect(totals.totalTokens).toBe(1_000_000);
// opus input $5/MTok → 5 credits for 1M prompt tokens.
expect(totals.creditsMicro).toBe(5 * CREDIT_MICROS);
});

it("records error rows with zero credits when the provider never resolved", async () => {
await service().recordUsage(
record({
provider: "",
model: "",
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
outcome: AiUsageOutcome.Error,
}),
);
const [app] = repo.summarizeByApp(0);
expect(app).toMatchObject({ calls: 1, errors: 1, creditsMicro: 0 });
});

it("is best-effort: an insert failure never throws", async () => {
const quota = service({
getUsageRepo: async () => {
throw new Error("disk full");
},
});
await expect(quota.recordUsage(record())).resolves.toBeUndefined();
});

it("prunes rows past retention opportunistically", async () => {
const now = Date.now();
repo.insert({
ts: now - AI_USAGE_RETENTION_MS - 1000,
appId: APP,
verb: "generate",
provider: "ollama",
model: "llama3.2",
promptTokens: 1,
completionTokens: 1,
totalTokens: 2,
creditsMicro: 0,
outcome: AiUsageOutcome.Ok,
durationMs: 1,
});
await service().recordUsage(record({ ts: now }));
expect(repo.totalsForApp(APP, 0).calls).toBe(1); // ancient row pruned
});

describe("bundled-credit debits (14.3 seam)", () => {
const bundledDeps = (isPlatformBilled: boolean, hasFeature = true) => ({
getCreditLedger: async () => creditLedger,
hasBundledCredits: async () => hasFeature,
isPlatformBilled: () => isPlatformBilled,
});

it("debits the ledger for a successful platform-billed call", async () => {
creditLedger.append({ ts: 1, kind: CreditEntryKind.Grant, creditsMicro: 50 * CREDIT_MICROS });
await service(bundledDeps(true)).recordUsage(
record({ promptTokens: 1_000_000, completionTokens: 0, totalTokens: 1_000_000 }),
);
expect(creditLedger.balanceMicro()).toBe(45 * CREDIT_MICROS);
const [debit] = creditLedger.unsynced().filter((e) => e.kind === CreditEntryKind.Debit);
expect(debit).toMatchObject({ appId: APP, provider: "anthropic" });
});

it("never debits BYO calls (production posture today)", async () => {
await service(bundledDeps(false)).recordUsage(record());
expect(creditLedger.balanceMicro()).toBe(0);
});

it("never debits without the BundledAiCredits entitlement", async () => {
await service(bundledDeps(true, false)).recordUsage(record());
expect(creditLedger.balanceMicro()).toBe(0);
});

it("never debits failed calls", async () => {
await service(bundledDeps(true)).recordUsage(record({ outcome: AiUsageOutcome.Error }));
expect(creditLedger.balanceMicro()).toBe(0);
});
});
});
});
Loading
Loading