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
25 changes: 23 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ app.route('/v1/agents', createAgentGateway({
authorizeConsumer: authorizeAgentAccess,
recordUsage: usageStore.recordUsage,
claimApiKeyRequest: createApiKeyRequestClaim(apiKeyStore),
apiKeyReservationLifecycle: apiKeyStore.reservations,
settlePayment: createApiKeyUsageSettlement(apiKeyStore),
x402: {
operatorAddress: '0x…',
Expand Down Expand Up @@ -82,8 +83,28 @@ Concurrent workers cannot claim the same slot, and a retry with the same request
When `verifyApiKey` returns a minute or daily limit, configure `claimApiKeyRequest` or the request fails closed with `503`.
The SQL store retains the current and previous UTC day, then prunes older claim rows every 256 accepted requests.
The API-key store also records each usage settlement once and refuses a settlement that would exceed the spending limit.
This check runs after work completes, so it does not reserve funds before an in-flight request.
Use a payment authorization flow when the product requires a strict pre-run budget.
Finite-cap keys also reserve the full quoted customer charge before execution.
The quote uses the greater of service-token charges and the provider-cost ceiling, matching settlement pricing.
Reservations use whole cents; provider budgets remain USD, and token limits remain token counts.
Concurrent workers cannot reserve more than the remaining key cap.
Settlement records actual charges once and releases the unused portion of the matching reservation.
Only a pre-execution reservation can be released after failure.
An execution without a final receipt retains its reservation until an authoritative receipt reconciles it.
Reservation rows are separate from rate counters and never expire through rate-counter pruning.

Existing installations must add the reservation table from `sqlApiKeyStoreSchemaStatements()` before adopting this version.
Its default name is `${table}_reservation`; `reservationTable` overrides it for custom schemas.
Wire `apiKeyReservationLifecycle: apiKeyStore.reservations` alongside the claim and settlement callbacks.
Do not delete executing reservations to recover capacity without first reconciling the underlying work.

Capped execution requires `SandboxBox.prepareBudgetedPrompt`.
Preparation must start no compute and return either an unsupported result or a prepared stream that enforces every supplied limit.
Enforcement includes provider calls, retries, child calls, and tools throughout that stream.
The gateway uses only this prepared stream for capped requests and requires a complete, enforced usage receipt.
Missing support fails with `api_key.execution_budget_unsupported` before compute; explicitly uncapped keys keep their existing execution path.
A receipt ceiling alone does not prove that upstream provider spending was bounded.
Current remote agent-app chat adapters forward execution limits but do not implement this preparation contract.
They remain unsupported for capped execution until the maintained Sandbox backend enforces per-turn limits across its complete execution lifecycle.
The usage store writes USD values as integer nanodollars instead of SQL floating-point values.

Production requires either `x402.verifySigner` or `verifyApiKey`.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tangle-network/agent-gateway",
"version": "0.9.0",
"version": "0.10.0",
"packageManager": "pnpm@10.28.0",
"engines": {
"node": ">=22.13.0"
Expand Down
64 changes: 64 additions & 0 deletions src/api-key-budget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { apiKeySettlementCostCents, ApiKeyRequestClaimUnavailableError } from './api-keys'
import type { AuthorizedRequest } from './dispatch-types'
import type { GatewayConfig, GatewaySandboxContext, SandboxBox, SandboxExecutionBudget } from './types'

export function apiKeyReservationQuote(authz: AuthorizedRequest, config: GatewayConfig): number | undefined {
const capped = authz.keyInfo?.spendingLimitCents !== undefined && authz.keyInfo.spendingLimitCents !== null
if (!capped) return undefined
if (!config.apiKeyReservationLifecycle) throw new ApiKeyRequestClaimUnavailableError('API key spending reservations are not configured')
const budget = authz.executionBudget
return apiKeySettlementCostCents(Math.max(
(budget.maxInputTokens + budget.maxOutputTokens + budget.maxReasoningTokens + budget.maxToolTokens) * authz.agent.pricePerTokenUsd,
budget.maxProviderCostUsd,
))
}

export async function prepareApiKeyPrompt(
box: SandboxBox, message: string, consumerId: string, systemPrompt: string | undefined,
executionBudget: SandboxExecutionBudget, signal: AbortSignal, sessionId?: string,
context?: GatewaySandboxContext,
) {
const promptOptions = { sessionId: sessionId ?? `consumer:${consumerId}`, systemPrompt,
maxOutputTokens: executionBudget.maxOutputTokens, executionBudget, signal }
if (!context?.apiKeyReservation) return { promptOptions, prepared: undefined }
if (!box.prepareBudgetedPrompt) throw new ApiKeyBudgetUnsupportedError('This execution adapter cannot enforce API key budgets')
const prepared = await box.prepareBudgetedPrompt(message, promptOptions)
if (prepared?.status === 'unsupported') throw new ApiKeyBudgetUnsupportedError(prepared.reason)
if (prepared?.status !== 'prepared' || typeof prepared.start !== 'function') {
throw new ApiKeyBudgetUnsupportedError('Execution adapter returned invalid budget preparation')
}
return { promptOptions, prepared }
}

export class ApiKeyBudgetUnsupportedError extends Error {
readonly code = 'api_key.execution_budget_unsupported'
constructor(message: string) { super(message); this.name = 'ApiKeyBudgetUnsupportedError' }
}

export function assertApiKeyRequestClaim(
claim: import('./types').ApiKeyRequestClaimResult,
): void {
if (typeof claim.allowed !== 'boolean') {
throw new ApiKeyRequestClaimUnavailableError('API key request claim is invalid')
}
for (const [name, value] of [
['minuteRemaining', claim.minuteRemaining],
['dailyRemaining', claim.dailyRemaining],
] as const) {
if (!Number.isSafeInteger(value) || value < 0) {
throw new ApiKeyRequestClaimUnavailableError(`API key request claim ${name} is invalid`)
}
}
for (const [name, value] of [
['minuteResetAt', claim.minuteResetAt],
['dailyResetAt', claim.dailyResetAt],
] as const) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new ApiKeyRequestClaimUnavailableError(`API key request claim ${name} is invalid`)
}
}
if (!claim.allowed && claim.reason !== 'minute' && claim.reason !== 'daily' && claim.reason !== 'spending') {
throw new ApiKeyRequestClaimUnavailableError('API key request claim reason is invalid')
}
}

57 changes: 57 additions & 0 deletions src/api-key-reservations-sql.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { SqlAdapter } from './a2a/task-store-sql'

/** Reservations outlive rate-counter pruning. A usage row atomically closes its matching hold. */
export class ApiKeyReservationsSql {
constructor(
private readonly db: SqlAdapter,
readonly table: string,
private readonly keys: string,
private readonly usage: string,
) {}

outstanding(excludeRequest = false): string {
return `COALESCE((SELECT SUM(r.reserved_cents) FROM ${this.table} r
WHERE r.key_id = k.id AND r.state <> 'released'
${excludeRequest ? 'AND r.request_id <> ?' : ''}
AND NOT EXISTS (SELECT 1 FROM ${this.usage} u WHERE u.request_id = r.request_id)), 0)`
}

async reserve(keyId: string, requestId: string, cents: number): Promise<boolean> {
if (!Number.isSafeInteger(cents) || cents < 0) throw new TypeError('Reservation cents must be a non-negative safe integer')
await this.db.exec(`INSERT INTO ${this.table} (request_id, key_id, reserved_cents, state, created_at)
SELECT ?, k.id, ?, 'reserved', ? FROM ${this.keys} k
WHERE k.id = ? AND (k.expires_at IS NULL OR k.expires_at > ?)
AND (k.spending_limit_cents IS NULL OR k.spent_cents
+ COALESCE((SELECT SUM(u.cost_cents) FROM ${this.usage} u WHERE u.key_id = k.id), 0)
+ ${this.outstanding()} + ? <= k.spending_limit_cents)
ON CONFLICT(request_id) DO NOTHING`, [requestId, cents, Math.floor(Date.now() / 1000), keyId, Math.floor(Date.now() / 1000), cents])
const row = (await this.db.query<{ key_id: string; reserved_cents: number; state: string }>(
`SELECT key_id, reserved_cents, state FROM ${this.table} WHERE request_id = ?`, [requestId],
))[0]
if (!row) return false
if (row.key_id !== keyId || Number(row.reserved_cents) !== cents) throw new Error('API key reservation id was reused with different terms')
if (row.state !== 'reserved') throw new Error('API key reservation is no longer available for execution')
if ((await this.db.query(`SELECT request_id FROM ${this.usage} WHERE request_id = ?`, [requestId])).length) {
throw new Error('API key reservation is already settled')
}
return true
}

async begin(keyId: string, requestId: string): Promise<void> {
const result = await this.db.exec(`UPDATE ${this.table} SET state = 'executing'
WHERE request_id = ? AND key_id = ? AND state = 'reserved'
AND EXISTS (SELECT 1 FROM ${this.keys} k WHERE k.id = ? AND (k.expires_at IS NULL OR k.expires_at > ?)
AND (k.spending_limit_cents IS NULL OR k.spent_cents
+ COALESCE((SELECT SUM(u.cost_cents) FROM ${this.usage} u WHERE u.key_id = k.id), 0)
+ ${this.outstanding()} <= k.spending_limit_cents))
AND NOT EXISTS (SELECT 1 FROM ${this.usage} WHERE request_id = ?)`,
[requestId, keyId, keyId, Math.floor(Date.now() / 1000), requestId])
if (result.rowsAffected !== 1) throw new Error('API key reservation cannot begin execution')
}

async release(keyId: string, requestId: string): Promise<void> {
// An execution handoff is uncertain even when it produced no visible output.
await this.db.exec(`UPDATE ${this.table} SET state = 'released'
WHERE request_id = ? AND key_id = ? AND state = 'reserved'`, [requestId, keyId])
}
}
39 changes: 34 additions & 5 deletions src/api-key-store-sql.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ApiKey, ApiKeyStore } from './api-keys'
import { ApiKeyReservationsSql } from './api-key-reservations-sql'
import type { SqlAdapter } from './a2a/task-store-sql'
import { requireSqlIdentifier } from './sql'
import type { ApiKeyRequestClaimResult } from './types'
Expand Down Expand Up @@ -43,6 +44,7 @@ export interface SqlApiKeyStoreOptions {
table?: string
usageTable?: string
requestTable?: string
reservationTable?: string
}

const REQUEST_CLAIM_PRUNE_INTERVAL = 256
Expand All @@ -53,6 +55,7 @@ export function sqlApiKeyStoreSchemaStatements(
const table = requireSqlIdentifier(options.table ?? 'agent_api_key')
const usageTable = requireSqlIdentifier(options.usageTable ?? `${table}_usage`)
const requestTable = requireSqlIdentifier(options.requestTable ?? `${table}_request`)
const reservationTable = requireSqlIdentifier(options.reservationTable ?? `${table}_reservation`)
return [
`CREATE TABLE IF NOT EXISTS ${table} (
id TEXT PRIMARY KEY,
Expand Down Expand Up @@ -93,6 +96,12 @@ export function sqlApiKeyStoreSchemaStatements(
ON ${requestTable} (key_id, created_at)`,
`CREATE INDEX IF NOT EXISTS idx_${requestTable}_day
ON ${requestTable} (key_id, day_bucket)`,
`CREATE TABLE IF NOT EXISTS ${reservationTable} (
request_id TEXT PRIMARY KEY, key_id TEXT NOT NULL, reserved_cents BIGINT NOT NULL,
state TEXT NOT NULL, created_at BIGINT NOT NULL,
FOREIGN KEY (key_id) REFERENCES ${table}(id) ON DELETE CASCADE
)`,
`CREATE INDEX IF NOT EXISTS idx_${reservationTable}_key ON ${reservationTable} (key_id)`,
]
}

Expand Down Expand Up @@ -141,6 +150,7 @@ export class SqlApiKeyStore implements ApiKeyStore {
private readonly table: string
private readonly usageTable: string
private readonly requestTable: string
readonly reservations: ApiKeyReservationsSql

constructor(
private readonly db: SqlAdapter,
Expand All @@ -149,8 +159,10 @@ export class SqlApiKeyStore implements ApiKeyStore {
this.table = requireSqlIdentifier(options.table ?? 'agent_api_key')
this.usageTable = requireSqlIdentifier(options.usageTable ?? `${this.table}_usage`)
this.requestTable = requireSqlIdentifier(options.requestTable ?? `${this.table}_request`)
if (new Set([this.table, this.usageTable, this.requestTable]).size !== 3) {
throw new TypeError('API key, usage, and request table names must differ')
const reservationTable = requireSqlIdentifier(options.reservationTable ?? `${this.table}_reservation`)
this.reservations = new ApiKeyReservationsSql(db, reservationTable, this.table, this.usageTable)
if (new Set([this.table, this.usageTable, this.requestTable, reservationTable]).size !== 4) {
throw new TypeError('API key, usage, request, and reservation table names must differ')
}
}

Expand All @@ -160,6 +172,7 @@ export class SqlApiKeyStore implements ApiKeyStore {
table: this.table,
usageTable: this.usageTable,
requestTable: this.requestTable,
reservationTable: this.reservations.table,
})) {
await this.db.exec(statement)
}
Expand Down Expand Up @@ -256,7 +269,21 @@ export class SqlApiKeyStore implements ApiKeyStore {
return result.rowsAffected > 0
}

async claimRequest(
async claimRequest(keyId: string, requestId: string, requestedAt = new Date(), reservationCents?: number): Promise<ApiKeyRequestClaimResult> {
const claim = await this.claimRateRequest(keyId, requestId, requestedAt)
if (!claim.allowed) return claim
if (reservationCents === undefined) {
const current = (await this.db.query<{ spending_limit_cents: number | null }>(
`SELECT spending_limit_cents FROM ${this.table} WHERE id = ?`, [keyId],
))[0]
if (!current || current.spending_limit_cents !== null) throw new Error('Finite API key caps require a spending reservation quote')
return claim
}
const reserved = await this.reservations.reserve(keyId, requestId, reservationCents)
return reserved ? { ...claim, reservedCents: reservationCents } : { ...claim, allowed: false, reason: 'spending' }
}

private async claimRateRequest(
keyId: string,
requestId: string,
requestedAt = new Date(),
Expand Down Expand Up @@ -352,10 +379,12 @@ export class SqlApiKeyStore implements ApiKeyStore {
k.spending_limit_cents IS NULL OR
k.spent_cents + COALESCE((
SELECT SUM(u.cost_cents) FROM ${this.usageTable} AS u WHERE u.key_id = k.id
), 0) + ? <= k.spending_limit_cents
), 0) + ${this.reservations.outstanding(true)} + ? <= k.spending_limit_cents
)
AND NOT EXISTS (SELECT 1 FROM ${this.reservations.table} r WHERE r.request_id = ?
AND (r.key_id <> k.id OR r.state = 'released' OR r.reserved_cents < ?))
ON CONFLICT(request_id) DO NOTHING`,
[usageRequestId, costCents, createdAt, keyId, costCents],
[usageRequestId, costCents, createdAt, keyId, usageRequestId, costCents, usageRequestId, costCents],
)
if (result.rowsAffected === 1) return

Expand Down
5 changes: 4 additions & 1 deletion src/api-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export interface ApiKeyStore {
keyId: string,
requestId: string,
requestedAt?: Date,
reservationCents?: number,
): Promise<ApiKeyRequestClaimResult>
}

Expand Down Expand Up @@ -121,6 +122,7 @@ export function createApiKeyRequestClaim(
input.keyInfo.keyId,
input.requestId,
input.requestedAt,
input.reservationCents,
)
}

Expand Down Expand Up @@ -163,7 +165,7 @@ export async function verifyApiKeyFromStore(
authHeader: string,
store: ApiKeyStore,
prefix = 'ak_',
): Promise<{ key: ApiKey; keyId: string; consumerId: string; ownerId: string; scopes: string[]; rateLimitPerMinute: number; dailyLimit: number } | null> {
): Promise<{ key: ApiKey; keyId: string; consumerId: string; ownerId: string; scopes: string[]; rateLimitPerMinute: number; dailyLimit: number; spendingLimitCents: number | null } | null> {
const bearerPrefix = `Bearer ${prefix}`
if (!authHeader.startsWith(bearerPrefix)) return null

Expand All @@ -186,6 +188,7 @@ export async function verifyApiKeyFromStore(
scopes: key.scopes,
rateLimitPerMinute: key.rateLimit,
dailyLimit: key.dailyLimit,
spendingLimitCents: key.spendingLimitCents,
}
}

Expand Down
43 changes: 15 additions & 28 deletions src/dispatch-payment.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { apiKeyReservationQuote, assertApiKeyRequestClaim } from './api-key-budget'
import {
assertMppChargeOperation,
mppPaymentOperationId,
Expand Down Expand Up @@ -47,19 +48,22 @@ export async function claimPayment(
if (!claimRequest) {
if (
authz.keyInfo.rateLimitPerMinute !== undefined ||
authz.keyInfo.dailyLimit !== undefined
authz.keyInfo.dailyLimit !== undefined ||
(authz.keyInfo.spendingLimitCents !== undefined && authz.keyInfo.spendingLimitCents !== null)
) {
throw new ApiKeyRequestClaimUnavailableError(
'API key request limits are not configured',
)
}
} else {
const reservationCents = apiKeyReservationQuote(authz, config)
let claim
try {
claim = await claimRequest({
keyInfo: authz.keyInfo,
requestId: authz.requestId,
requestedAt: new Date(authz.startMs),
...(reservationCents !== undefined ? { reservationCents } : {}),
})
} catch (error) {
if (
Expand All @@ -73,6 +77,10 @@ export async function claimPayment(
}
assertApiKeyRequestClaim(claim)
if (!claim.allowed) throw new ApiKeyRequestLimitExceededError(claim)
if (reservationCents !== undefined) {
if (claim.reservedCents !== reservationCents) throw new ApiKeyRequestClaimUnavailableError('API key spending reservation is invalid')
authz.apiKeyReservedCents = reservationCents
}
authz.rateLimitRemaining = Math.min(
authz.rateLimitRemaining ?? claim.minuteRemaining,
claim.minuteRemaining,
Expand Down Expand Up @@ -264,39 +272,15 @@ export async function claimPayment(
}
}

function assertApiKeyRequestClaim(
claim: import('./types').ApiKeyRequestClaimResult,
): void {
if (typeof claim.allowed !== 'boolean') {
throw new ApiKeyRequestClaimUnavailableError('API key request claim is invalid')
}
for (const [name, value] of [
['minuteRemaining', claim.minuteRemaining],
['dailyRemaining', claim.dailyRemaining],
] as const) {
if (!Number.isSafeInteger(value) || value < 0) {
throw new ApiKeyRequestClaimUnavailableError(`API key request claim ${name} is invalid`)
}
}
for (const [name, value] of [
['minuteResetAt', claim.minuteResetAt],
['dailyResetAt', claim.dailyResetAt],
] as const) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new ApiKeyRequestClaimUnavailableError(`API key request claim ${name} is invalid`)
}
}
if (!claim.allowed && claim.reason !== 'minute' && claim.reason !== 'daily') {
throw new ApiKeyRequestClaimUnavailableError('API key request claim reason is invalid')
}
}

/** Release an owned operation when execution cannot produce a valid receipt. */
export async function releasePayment(
authz: AuthorizedRequest,
config: GatewayConfig,
reason: string,
): Promise<void> {
if (authz.apiKeyReservedCents !== undefined && authz.keyInfo) {
await config.apiKeyReservationLifecycle!.release(authz.keyInfo.keyId, authz.requestId)
}
const ownsX402 = authz.paymentOperation &&
authz.paymentOperationAcquired === true &&
config.x402.paymentOperations
Expand Down Expand Up @@ -356,6 +340,9 @@ export async function markPaymentExecutionStarted(
authz: AuthorizedRequest,
config: GatewayConfig,
): Promise<void> {
if (authz.apiKeyReservedCents !== undefined && authz.keyInfo) {
await config.apiKeyReservationLifecycle!.begin(authz.keyInfo.keyId, authz.requestId)
}
await updateExecutionLease(authz, config, true)
}

Expand Down
Loading