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
35 changes: 35 additions & 0 deletions src/billing/admission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import type { AccountRecord } from "../web/accounts.js";
import type { AdmissionDependencies } from "./admission.js";
import type { TurnLease } from "../cloud/turn-lease.js";
import type { UsageRecord } from "./meter.js";

const { admitInference } = await import("./admission.js");

Expand All @@ -16,6 +17,17 @@ const ACCT: AccountRecord = {
sessionVersion: 0,
};
const LEASE: TurnLease = "off";
const USAGE_RECORD: UsageRecord = {
at: "2026-07-26T00:00:00.000Z",
source: "test",
model: "glm-4.6",
inputTokens: 1,
outputTokens: 2,
cacheReadTokens: 0,
cacheWriteTokens: 0,
microUSD: 3,
pricesVersion: 1,
};

function deps(overrides: Partial<AdmissionDependencies> = {}): {
value: AdmissionDependencies;
Expand Down Expand Up @@ -44,6 +56,10 @@ function deps(overrides: Partial<AdmissionDependencies> = {}): {
releaseLease: async () => {
calls.push("release");
},
settle: async () => {
calls.push("settle");
return USAGE_RECORD;
},
...overrides,
},
};
Expand Down Expand Up @@ -110,6 +126,25 @@ describe("admitInference", () => {
assert.deepEqual(d.calls, ["limits", "acquire", "quota", "renew", "stop", "release"]);
});

test("settlement is owned by the permit and is idempotent", async () => {
const d = deps();
const result = await admitInference(ACCT, "glm-4.6", d.value);
assert.equal(result.ok, true);
if (!result.ok) return;
const usage = {
inputTokens: 1,
outputTokens: 2,
cacheReadTokens: 0,
cacheWriteTokens: 0,
};
const first = await result.permit.settle("reflect", usage);
const second = await result.permit.settle("ignored", { ...usage, outputTokens: 999 });
assert.equal(first, USAGE_RECORD);
assert.equal(second, USAGE_RECORD);
assert.equal(d.calls.filter((call) => call === "settle").length, 1);
await result.permit.release();
});

test("a quota storage failure cannot leak the lease", async () => {
const d = deps({
precheck: async () => {
Expand Down
27 changes: 25 additions & 2 deletions src/billing/admission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
*/
import type { AccountRecord } from "../web/accounts.js";
import { preflightLimits, type LimitVerdict } from "./limits.js";
import { precheckTurn, type PrecheckResult } from "./quota.js";
import { debitTurn, precheckTurn, type PrecheckResult } from "./quota.js";
import { recordUsage, type UsageRecord } from "./meter.js";
import type { ProviderUsage } from "../providers/types.js";
import {
acquireTurnLease,
releaseTurnLease,
Expand All @@ -17,6 +19,8 @@ import {

export interface InferencePermit {
budgetMicroUSD: number;
/** Price, audit and debit this permit's aggregated provider usage once. */
settle(source: string, usage: ProviderUsage): Promise<UsageRecord>;
release(): Promise<void>;
}

Expand All @@ -30,6 +34,12 @@ export interface AdmissionDependencies {
acquire(uid: string): Promise<TurnLease | null>;
startRenewal(lease: TurnLease): () => void;
releaseLease(lease: TurnLease): Promise<void>;
settle(
acct: AccountRecord,
source: string,
model: string,
usage: ProviderUsage,
): Promise<UsageRecord>;
}

const DEFAULT_DEPS: AdmissionDependencies = {
Expand All @@ -38,6 +48,11 @@ const DEFAULT_DEPS: AdmissionDependencies = {
acquire: acquireTurnLease,
startRenewal: startLeaseRenewal,
releaseLease: releaseTurnLease,
settle: async (acct, source, model, usage) => {
const record = await recordUsage(source, model, usage);
await debitTurn(acct, model, record.microUSD);
return record;
},
};

function quotaRejection(pre: Exclude<PrecheckResult, { ok: true }>): InferenceAdmission {
Expand Down Expand Up @@ -73,6 +88,7 @@ export async function admitInference(
}

let released = false;
let settlement: Promise<UsageRecord> | null = null;
let stopRenewal: () => void = () => {};
const release = async (): Promise<void> => {
if (released) return;
Expand All @@ -90,7 +106,14 @@ export async function admitInference(
stopRenewal = deps.startRenewal(lease);
return {
ok: true,
permit: { budgetMicroUSD: pre.budgetMicroUSD, release },
permit: {
budgetMicroUSD: pre.budgetMicroUSD,
settle: (source, usage) => {
settlement ??= deps.settle(acct, source, model, usage);
return settlement;
},
release,
},
};
} catch (err) {
await release();
Expand Down
11 changes: 11 additions & 0 deletions src/reflect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import {
parseReflectionPayload,
detectUnderReflection,
addProviderUsage,
UNDERREFLECT_MIN_HISTORY,
} from "./reflect.js";

Expand Down Expand Up @@ -71,3 +72,13 @@ describe("detectUnderReflection", () => {
);
});
});

test("addProviderUsage preserves every billable token class", () => {
assert.deepEqual(
addProviderUsage(
{ inputTokens: 1, outputTokens: 2, cacheReadTokens: 3, cacheWriteTokens: 4 },
{ inputTokens: 10, outputTokens: 20, cacheReadTokens: 30, cacheWriteTokens: 40 },
),
{ inputTokens: 11, outputTokens: 22, cacheReadTokens: 33, cacheWriteTokens: 44 },
);
});
43 changes: 30 additions & 13 deletions src/reflect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,15 @@ export function detectUnderReflection(opts: {
return opts.historyLength >= UNDERREFLECT_MIN_HISTORY && opts.operationCount === 0;
}

export function addProviderUsage(a: ProviderUsage, b: ProviderUsage): ProviderUsage {
return {
inputTokens: a.inputTokens + b.inputTokens,
outputTokens: a.outputTokens + b.outputTokens,
cacheReadTokens: a.cacheReadTokens + b.cacheReadTokens,
cacheWriteTokens: a.cacheWriteTokens + b.cacheWriteTokens,
};
}

export async function reflectOnSession(opts: {
history: StoredMessage[];
sessionId: string;
Expand Down Expand Up @@ -186,8 +195,12 @@ async function reflectOnSessionInner(opts: {
const provider = providerForModel(model);
const startedAt = new Date().toISOString();
const t0 = Date.now();
let inTok = 0;
let outTok = 0;
let totalUsage: ProviderUsage = {
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
};

const runReflector = async (systemPrompt: string): Promise<string> => {
const result = await provider.runTurn({
Expand All @@ -207,8 +220,7 @@ async function reflectOnSessionInner(opts: {
],
maxTokens: 2_000,
});
inTok += result.usage.inputTokens;
outTok += result.usage.outputTokens;
totalUsage = addProviderUsage(totalUsage, result.usage);
return result.content
.filter((b) => b.type === "text")
.map((b) => (b as { text: string }).text)
Expand Down Expand Up @@ -250,8 +262,8 @@ async function reflectOnSessionInner(opts: {
kind: "reflect",
startedAt,
durationMs: Date.now() - t0,
inputTokens: inTok,
outputTokens: outTok,
inputTokens: totalUsage.inputTokens,
outputTokens: totalUsage.outputTokens,
outcome: "error",
note: `malformed: ${firstError}`.slice(0, 200),
});
Expand All @@ -261,7 +273,7 @@ async function reflectOnSessionInner(opts: {
skipped: [`raw: ${raw.slice(0, 200)}`],
raw,
malformed: true,
usage: { inputTokens: inTok, outputTokens: outTok, cacheReadTokens: 0, cacheWriteTokens: 0 },
usage: totalUsage,
};
}
}
Expand Down Expand Up @@ -398,7 +410,10 @@ async function reflectOnSessionInner(opts: {
// raw entries above the threshold.
try {
const consolidated = await maybeConsolidateOneDesireProgress(model);
if (consolidated) applied.push(`progress_consolidated:${consolidated}`);
if (consolidated) {
totalUsage = addProviderUsage(totalUsage, consolidated.usage);
applied.push(`progress_consolidated:${consolidated.slug}`);
}
} catch (err) {
skipped.push(`progress_consolidation — ${(err as Error).message}`);
}
Expand Down Expand Up @@ -429,8 +444,8 @@ async function reflectOnSessionInner(opts: {
kind: "reflect",
startedAt,
durationMs: Date.now() - t0,
inputTokens: inTok,
outputTokens: outTok,
inputTokens: totalUsage.inputTokens,
outputTokens: totalUsage.outputTokens,
outcome: opsApplied > 0 ? "done" : "no-update",
note: underReflected ? "underreflected" : undefined,
});
Expand All @@ -441,7 +456,7 @@ async function reflectOnSessionInner(opts: {
skipped,
raw,
underReflected,
usage: { inputTokens: inTok, outputTokens: outTok, cacheReadTokens: 0, cacheWriteTokens: 0 },
usage: totalUsage,
};
}

Expand All @@ -456,7 +471,9 @@ async function reflectOnSessionInner(opts: {
const PROGRESS_CONSOLIDATE_THRESHOLD = 8;
const PROGRESS_KEEP_LATEST = 4;

async function maybeConsolidateOneDesireProgress(model: string): Promise<string | null> {
async function maybeConsolidateOneDesireProgress(
model: string,
): Promise<{ slug: string; usage: ProviderUsage } | null> {
const { listDesires, parseDesireProgress, consolidateDesireProgress } = await import("./soul/store.js");
const { withSoulCaller } = await import("./soul/git.js");
const desires = (await listDesires()).filter((d) => d.actionable);
Expand Down Expand Up @@ -501,7 +518,7 @@ async function maybeConsolidateOneDesireProgress(model: string): Promise<string
keepLatest,
});
});
return target.slug;
return { slug: target.slug, usage: result.usage };
}

function renderTranscript(history: StoredMessage[]): string {
Expand Down
5 changes: 1 addition & 4 deletions src/web/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@ import type http from "node:http";
import { findPreset } from "../providers/registry.js";
import type { ProviderUsage } from "../providers/types.js";
import type { AccountRecord } from "./accounts.js";
import { debitTurn } from "../billing/quota.js";
import { recordUsage } from "../billing/meter.js";
import { admitInference } from "../billing/admission.js";
import { readCappedText, BodyTooLargeError } from "./http-body.js";

Expand Down Expand Up @@ -244,8 +242,7 @@ export async function handleGateway(
const u = upstream.ok && usageIsEmpty(usage)
? estimateUsageFromBytes(requestBytes, responseBytes)
: usage;
const rec = await recordUsage("gw", model, u);
await debitTurn(acct, model, rec.microUSD);
await admission.permit.settle("gw", u);
};

const contentType = upstream.headers.get("content-type") ?? "application/json";
Expand Down
61 changes: 46 additions & 15 deletions src/web/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,7 @@ import { ISLAND_HTML } from "./island.js";
import { LOGIN_HTML } from "./login.js";
import { recordUsage, summarizeUsage, setAnomalySink } from "../billing/meter.js";
import { PRICES_VERSION, tokensAffordable } from "../billing/prices.js";
import {
debitTurn,
quotaStatus,
BillingStateError,
} from "../billing/quota.js";
import { quotaStatus, BillingStateError } from "../billing/quota.js";
import { admitInference, type InferencePermit } from "../billing/admission.js";
import {
verifyAppleJWS,
Expand Down Expand Up @@ -3562,17 +3558,17 @@ self.addEventListener('fetch', (event) => {
// per-uid subtree for signed-in cloud accounts. Mac edition (BYO key)
// is not metered. Never throws.
if (cloud) {
// Price + debit are INDEPENDENT of the audit-log append (#264):
// recordUsage always returns the priced record even if usage.jsonl
// couldn't be written, so a full disk can't ship a free turn.
const rec = await recordUsage("chat", opts.model, {
const usage = {
inputTokens: result.inputTokens,
outputTokens: result.outputTokens,
cacheReadTokens: result.cacheReadTokens,
cacheWriteTokens: result.cacheWriteTokens,
});
// Debit order (B4): free window first, then paid balance.
if (quotaAcct) await debitTurn(quotaAcct, opts.model, rec.microUSD);
};
// Signed-in account turns settle through the same permit that
// admitted them. The legacy shared-token cloud demo has no account
// balance, so it remains operator-funded but still audited.
if (inferencePermit) await inferencePermit.settle("chat", usage);
else await recordUsage("chat", opts.model, usage);
}
if (!anyText && !anyTool && !errorSent) send({ type: "empty" });
send({ type: "done" });
Expand Down Expand Up @@ -3601,20 +3597,55 @@ self.addEventListener('fetch', (event) => {
}

if (req.method === "POST" && url === "/reflect") {
const runtime = await ctxForRequest();
let inferencePermit: InferencePermit | null = null;
try {
const acct = cloud && accountUid ? await getAccount(accountUid) : null;
if (acct) {
const admission = await admitInference(acct, opts.model);
if (!admission.ok) {
res.writeHead(admission.status, { "content-type": "application/json" });
res.end(JSON.stringify(admission.body));
return;
}
inferencePermit = admission.permit;
}
} catch (err) {
if (!(err instanceof BillingStateError || err instanceof AccountStoreError)) throw err;
console.error(`[billing] reflection admission unavailable: ${err.message}`);
res.writeHead(503, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "billing_state_unavailable" }));
return;
}
let runtime: TenantRuntimeLease<ChatCtx>;
try {
runtime = await ctxForRequest();
} catch (err) {
await inferencePermit?.release();
throw err;
}
const chat = runtime.value;
try {
const r = await reflectOnSession({
history: chat.history,
sessionId: chat.session.id,
model: opts.model,
});
if (inferencePermit && r.usage) {
await inferencePermit.settle("reflect", r.usage);
}
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(r));
} catch (err) {
res.writeHead(500);
res.end((err as Error).message);
if (err instanceof BillingStateError || err instanceof AccountStoreError) {
console.error(`[billing] reflection settlement unavailable: ${err.message}`);
res.writeHead(503, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "billing_state_unavailable" }));
} else {
res.writeHead(500);
res.end((err as Error).message);
}
} finally {
await inferencePermit?.release();
runtime.release();
}
return;
Expand Down