Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
18 changes: 10 additions & 8 deletions .specs/gastown-usage-based-billing.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,13 @@

## Role of This Document

This spec defines how Gastown charges Kilo credits for Cloudflare Container usage. It is the
source of truth for pricing, payer attribution, metering lifecycle, budget enforcement, and
user-visible behavior. The `@kilocode/container-usage` interface described here is proposed
and is not yet available in production.
This is retained design history for the Gastown producer. The authoritative rollout and
settlement design is `fd-plans/research/container-billing-charge-and-enforce.md`; where this
document differs from that plan, the plan wins.

## Status

Draft -- created 2026-07-21.
Superseded for rollout and accounting decisions -- created 2026-07-21.

## Conventions

Expand All @@ -24,8 +23,8 @@ settle continuously against the owner's Kilo credit balance through the future
and enforcing budget verdicts; the metering service owns usage calculation, pricing, ledger
debits, idempotency, and balance evaluation.

The initial price is **three times the attributable Cloudflare Container cost**. This is a
usage-based charge, not a subscription or flat-rate entitlement.
Pricing is the immutable rate snapshotted from the accepted SKU. Do not introduce a generic
provider-cost multiplier in the producer.

## Definitions

Expand Down Expand Up @@ -114,7 +113,10 @@ reservations, credit debits, or remaining balance. Therefore `GASTOWN_BILLING_EN
off for customer charging until those ledger capabilities are implemented; the current integration
is suitable for shadow metering and reconciliation.

### Required admission contract before customer charging
### Superseded admission design

The following reservation design is superseded. The meter-owned `recordStart` balance check is
the authoritative cold-start admission decision; no hold or fourth authorization RPC is planned.

The current three recording calls are not sufficient by themselves to prevent a cold start:
`recordStart` returns no budget verdict, while `recordHeartbeat` is defined only after
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,37 +2,48 @@ import { NextResponse } from 'next/server';
import { captureException } from '@sentry/nextjs';
import { db } from '@/lib/drizzle';
import { CRON_SECRET } from '@/lib/config.server';
import { provisionExaUsageLogPartitions } from '@/lib/exa-usage-partitions';
import {
provisionComputeUsageChargePartitions,
provisionExaUsageLogPartitions,
} from '@/lib/usage-partitions';

if (!CRON_SECRET) {
throw new Error('CRON_SECRET is not configured in environment variables');
}

/**
* Exa Usage Log Partition Maintenance
* Usage Ledger Partition Maintenance
*
* Run monthly. Creates the next two months' partitions (idempotent).
* Old partitions are retained indefinitely — the recompute balance
* functions depend on the full exa_usage_log history.
* Old partitions are retained indefinitely because balance recomputation
* depends on the full history of each usage ledger.
*/
export async function GET(request: Request) {
const authHeader = request.headers.get('authorization');
if (authHeader !== `Bearer ${CRON_SECRET}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const { created, errors: partitionErrors } = await provisionExaUsageLogPartitions(db);
const [
{ created: exaCreated, errors: exaErrors },
{ created: chargeCreated, errors: chargeErrors },
] = await Promise.all([
provisionExaUsageLogPartitions(db),
provisionComputeUsageChargePartitions(db),
]);
const created = [...exaCreated, ...chargeCreated];
const partitionErrors = [...exaErrors, ...chargeErrors];
const errors: string[] = [];

for (const { name, error } of partitionErrors) {
const msg = `Failed to create partition ${name}: ${error instanceof Error ? error.message : String(error)}`;
console.error(`[exa-partition-maintenance] ${msg}`);
captureException(error, { tags: { source: 'exa-partition-maintenance', partition: name } });
console.error(`[usage-partition-maintenance] ${msg}`);
captureException(error, { tags: { source: 'usage-partition-maintenance', partition: name } });
errors.push(msg);
}

console.log(
`[exa-partition-maintenance] created=[${created.join(', ')}] errors=${errors.length}`
`[usage-partition-maintenance] created=[${created.join(', ')}] errors=${errors.length}`
);

return NextResponse.json({
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/lib/ai-gateway/spend-writer-audit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const classifiedIncrementWriters = {
'apps/web/src/lib/kiloclaw/credit-billing.ts': 'included_kiloclaw_enrollment',
'apps/web/src/lib/organizations/organization-usage.ts':
'included_ai_gateway_and_exa_organization',
'services/container-usage-meter/src/postgres.ts': 'included_container_usage_settlement',
'services/kiloclaw-billing/src/lifecycle.ts': 'included_kiloclaw_renewal',
'apps/web/src/app/admin/api/organizations/[id]/consume-credits/route.ts':
'excluded_development_consume_route',
Expand Down
19 changes: 17 additions & 2 deletions apps/web/src/lib/recomputeOrganizationBalances.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
credit_transactions,
microdollar_usage,
exa_usage_log,
compute_usage_charge,
type Organization,
} from '@kilocode/db/schema';
import { eq, and, asc, gt } from 'drizzle-orm';
Expand Down Expand Up @@ -84,7 +85,21 @@ export async function recomputeOrganizationBalances(args: {
)
.orderBy(asc(exa_usage_log.created_at));

const usageRecords = mergeSortedByCreatedAt(llmUsage, exaUsage);
const computeUsage = await db
.select({
cost: compute_usage_charge.amount_microdollars,
created_at: compute_usage_charge.created_at,
})
.from(compute_usage_charge)
.where(
and(
eq(compute_usage_charge.organization_id, args.organizationId),
gt(compute_usage_charge.amount_microdollars, 0)
)
)
.orderBy(asc(compute_usage_charge.created_at));

const usageRecords = mergeSortedByCreatedAt(llmUsage, exaUsage, computeUsage);

// Fetch all credit transactions for this org
const creditTransactions = await db
Expand All @@ -105,7 +120,7 @@ export async function recomputeOrganizationBalances(args: {
.orderBy(asc(credit_transactions.created_at));

// Compute total usage AND original baselines in a single pass.
// usageRecords contains both LLM and Exa charged records, merge-sorted
// usageRecords contains LLM, Exa, and metered-compute charges, merge-sorted
// by created_at, so baselines are computed at the correct points in time.
const computedOriginalBaselines = new Map<string, number>();
let usageIdx = 0;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import {
buildExaUsageLogPartitionIndexDefinitions,
buildExaUsageLogPartitionIndexDropStatement,
provisionComputeUsageChargePartitions,
provisionExaUsageLogPartitions,
} from '@/lib/exa-usage-partitions';
} from '@/lib/usage-partitions';
import type { SQL } from 'drizzle-orm';
import { PgDialect } from 'drizzle-orm/pg-core';

Expand Down Expand Up @@ -100,3 +101,36 @@ describe('Exa usage-log partition indexes', () => {
]);
});
});

describe('compute usage charge partitions', () => {
test('provisions current and next two monthly partitions', async () => {
const statements: string[] = [];
const dialect = new PgDialect();
const fakeDb = {
execute: async (query: SQL) => {
statements.push(dialect.sqlToQuery(query).sql);
return { rows: [] };
},
};

const result = await provisionComputeUsageChargePartitions(
fakeDb as never,
new Date(2026, 7, 4, 12)
);

expect(result).toEqual({
created: [
'compute_usage_charge_2026_08',
'compute_usage_charge_2026_09',
'compute_usage_charge_2026_10',
],
errors: [],
});
expect(statements).toHaveLength(3);
expect(statements[0]).toContain(
'PARTITION OF "public"."compute_usage_charge" FOR VALUES FROM (\'2026-08-01\') TO (\'2026-09-01\')'
);
expect(statements[1]).toContain('"compute_usage_charge_2026_09"');
expect(statements[2]).toContain('"compute_usage_charge_2026_10"');
});
});
Comment thread
pandemicsyn marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import type { db as defaultDb } from '@/lib/drizzle';
import { sql } from 'drizzle-orm';
import { format } from 'date-fns';

type ExaPartitionDb = Pick<typeof defaultDb, 'execute'>;
type UsagePartitionDb = Pick<typeof defaultDb, 'execute'>;

export type ExaUsageLogPartitionProvisioningResult = {
export type UsagePartitionProvisioningResult = {
created: string[];
errors: Array<{ name: string; error: unknown }>;
};
Expand Down Expand Up @@ -79,9 +79,9 @@ export function buildExaUsageLogPartitionIndexDefinitions(
* failed partition as fatal after calling this best-effort helper.
*/
export async function provisionExaUsageLogPartitions(
fromDb: ExaPartitionDb,
fromDb: UsagePartitionDb,
now: Date = new Date()
): Promise<ExaUsageLogPartitionProvisioningResult> {
): Promise<UsagePartitionProvisioningResult> {
const created: string[] = [];
const errors: Array<{ name: string; error: unknown }> = [];

Expand Down Expand Up @@ -114,3 +114,29 @@ export async function provisionExaUsageLogPartitions(

return { created, errors };
}

/** Keeps the metered-compute debit ledger writable through the current and next two months. */
export async function provisionComputeUsageChargePartitions(
fromDb: UsagePartitionDb,
now: Date = new Date()
): Promise<UsagePartitionProvisioningResult> {
const created: string[] = [];
const errors: Array<{ name: string; error: unknown }> = [];

for (let offset = 0; offset <= 2; offset++) {
const target = new Date(now.getFullYear(), now.getMonth() + offset, 1);
const nextMonth = new Date(target.getFullYear(), target.getMonth() + 1, 1);
const name = `compute_usage_charge_${format(target, 'yyyy_MM')}`;
try {
await fromDb.execute(
sql.raw(
`CREATE TABLE IF NOT EXISTS "public"."${name}" PARTITION OF "public"."compute_usage_charge" FOR VALUES FROM ('${format(target, 'yyyy-MM-dd')}') TO ('${format(nextMonth, 'yyyy-MM-dd')}')`
)
);
created.push(name);
} catch (error) {
errors.push({ name, error });
}
}
return { created, errors };
}
26 changes: 20 additions & 6 deletions apps/web/src/lib/user/recompute-balances.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
credit_transactions,
microdollar_usage,
exa_usage_log,
compute_usage_charge,
type User,
} from '@kilocode/db/schema';
import { eq, and, isNull, gt, asc } from 'drizzle-orm';
Expand All @@ -31,7 +32,9 @@ export type MigrationResult = Result<UserBalanceUpdates, string>;
* 6. Updates the user record and transaction baselines (unless dryRun is true).
*
* Postconditions:
* - microdollars_used = sum(microdollar_usage) + sum(exa_usage_log where charged_to_balance, personal)
* - microdollars_used = sum(personal microdollar_usage.cost)
* + sum(personal exa_usage_log.cost_microdollars where charged_to_balance)
* + sum(compute_usage_charge.amount_microdollars where user_id matches)
* - total_microdollars_acquired = sum(credit_transactions) [including any new adjustment]
* - All expiring credit transactions have expiration_baseline_microdollars_used set
*/
Expand Down Expand Up @@ -106,7 +109,18 @@ async function fetchUserBalanceData(userId: string) {
)
.orderBy(asc(exa_usage_log.created_at));

const usageRecords = mergeSortedByCreatedAt(llmUsage, exaUsage);
const computeUsage = await db
.select({
cost: compute_usage_charge.amount_microdollars,
created_at: compute_usage_charge.created_at,
})
.from(compute_usage_charge)
.where(
and(eq(compute_usage_charge.user_id, userId), gt(compute_usage_charge.amount_microdollars, 0))
)
.orderBy(asc(compute_usage_charge.created_at));

const usageRecords = mergeSortedByCreatedAt(llmUsage, exaUsage, computeUsage);

const creditTransactions = await db
.select({
Expand Down Expand Up @@ -136,7 +150,7 @@ export function computeUserBalanceUpdates(
const { user, usageRecords, creditTransactions } = data;

// Compute total usage AND original baselines in a single pass.
// usageRecords contains both LLM and Exa charged records, merge-sorted
// usageRecords contains LLM, Exa, and metered-compute charges, merge-sorted
// by created_at, so baselines are computed at the correct points in time.
const computedOriginalBaselines = new Map<string, number>();
let usageIdx = 0;
Expand Down Expand Up @@ -286,7 +300,7 @@ async function applyUserBalanceUpdates(updates: UserBalanceUpdates): Promise<boo

type UsageRecord = { cost: number; created_at: string };

/** Merge two arrays into a single list sorted by `created_at`. */
export function mergeSortedByCreatedAt(a: UsageRecord[], b: UsageRecord[]): UsageRecord[] {
return [...a, ...b].sort((x, y) => x.created_at.localeCompare(y.created_at));
/** Merge usage ledgers into a single chronology for balance-baseline reconstruction. */
export function mergeSortedByCreatedAt(...records: UsageRecord[][]): UsageRecord[] {
return records.flat().sort((x, y) => x.created_at.localeCompare(y.created_at));
}
3 changes: 3 additions & 0 deletions apps/web/src/routers/admin/cloud-billing-skus-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,9 @@ describe('admin.cloudBillingSkus usage records', () => {
last_seen_at: '2026-04-29 01:17:12.945+00',
last_heartbeat_seq: 1,
confirmed_seconds: 60,
billing_mode: 'shadow',
rate_cents_per_unit: null,
settled_billable_seconds: 0,
stopped_at: '2026-04-29 01:17:12.945+00',
close_reason: 'exit',
exit_code: 0,
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/scripts/db/exa-usage-log-indexes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { db, type db as defaultDb } from '@/lib/drizzle';
import {
buildExaUsageLogPartitionIndexDefinitions,
buildExaUsageLogPartitionIndexDropStatement,
} from '@/lib/exa-usage-partitions';
} from '@/lib/usage-partitions';
import { sql } from 'drizzle-orm';

export type ExaUsageLogIndexScriptArgs = {
Expand Down
13 changes: 12 additions & 1 deletion apps/web/src/tests/setup/workerSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import { LEGACY_KILOCLAW_PRICE_VERSION } from '@kilocode/db';
import { kiloclaw_subscriptions } from '@kilocode/db/schema';
import { drizzle } from 'drizzle-orm/node-postgres';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { provisionExaUsageLogPartitions } from '@/lib/exa-usage-partitions';
import {
provisionComputeUsageChargePartitions,
provisionExaUsageLogPartitions,
} from '@/lib/usage-partitions';
import { provisionModelExperimentRequestPartitions } from '@/lib/model-experiment-request-partitions';
import { existsSync, writeFileSync, mkdirSync } from 'fs';
import { join } from 'path';
Expand Down Expand Up @@ -65,6 +68,14 @@ beforeAll(async () => {
);
}

const { errors: chargePartitionErrors } = await provisionComputeUsageChargePartitions(testDb);
if (chargePartitionErrors.length > 0) {
const [{ name, error }] = chargePartitionErrors;
throw new Error(
`Failed to create compute usage charge partition ${name}: ${error instanceof Error ? error.message : String(error)}`
);
}

const { errors: modelExperimentPartitionErrors } =
await provisionModelExperimentRequestPartitions(testDb);
if (modelExperimentPartitionErrors.length > 0) {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/vercel.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@
"schedule": "0 * * * *"
},
{
"path": "/api/cron/exa-partition-maintenance",
"path": "/api/cron/usage-partition-maintenance",
"schedule": "0 0 1 * *"
},
{
Expand Down
Loading
Loading