/** * hosting/admission — decide whether a request runs, before it costs anything. * * A standing agent's cheapest refusal is the one made before the first model * call. Everything else the composer does about load is about ORDER — * `onConcurrentInvoke` decides whether a session's second turn waits or is * refused, the pool decides which instance answers — and none of it can say * "this person has had twenty turns in the last hour and that is enough". * * That question needs two things the composer already has and never joined: WHO * is asking (verified, since 9.26) and what that person has spent (the token * and cost events every run already emits). This module is the join, plus the * seam where an operator's own rule goes. * * **Status, split rather than averaged, because the two halves were not earned * equally:** * * • **The `admission: { decide }` seam is field-validated — an independent * field trial, 2026-08-13.** Running against a real hosted door (a live * `nodeHost` in a live Google Cloud project, sessions in a real Firestore), * the trial had one caller's first two turns run and the third answer **429 * before the provider or the session store was invoked** — the * cheapest-refusal claim, measured rather than asserted. * * • **`turnsPerHour` itself is contract-shaped and tested.** The finding * records the refusal and restates this helper's per-process bound; it does * not record the shipped helper as the policy under test, and a run of a few * minutes cannot have crossed an HOUR boundary in any case. So the window's * own arithmetic — the roll-off, the reset a caller is told about — is * proven here by tests and not by a field run. * * The honest bound the trial restated is the one already on {@link RecentSpend}: * `turnsPerHour` is an in-process reference policy, its counters are not shared * across instances and do not survive a restart, so a fleet-wide or billing * control needs a `decide` that reads a transactional store of your own. * * The refusal itself is invisible to `auditExport()` — it happens before a run * exists. `standingAgent({ onIngressDecision })` is where it lands instead. * * ── One decision, three answers, no fourth ─────────────────────────────────── * `'allow'` · `{ queue: true }` · `{ refuse: '' }`. Deliberately not * "delay by N ms" (a sleep is a thread the caller is paying for), not "degrade * to a cheaper model" (that is the run's business, and silently answering from * a different brain is the accepted-and-silently-wrong failure), and not a * numeric priority (a priority means a scheduler, and this is a door). * * ── The refusal is the operator's sentence, and that is deliberate ─────────── * The policy WRITES the words. A limit and its reset are facts only the * operator has, and a library-authored "rate limit exceeded" is a support * ticket rather than something a caller can act on. `turnsPerHour` shows the * shape: name the limit, name when it resets, name what to do. * * ── What "spend" honestly means here ───────────────────────────────────────── * PER PROCESS. This ledger counts what THIS process served, in a rolling * window, in memory. Two replicas keep two windows; a restart forgets. That is * not a defect to be apologised for — it is what an in-process accountant can * truthfully claim — and it is stated on {@link RecentSpend} so nobody builds a * billing control on it by accident. A deployment that needs one number across * a fleet writes a `decide` that reads its own store; the seam is the same. */ import type { VerifiedIdentity } from './identityVerification.js'; /** * What one caller has spent inside the rolling window, as THIS PROCESS saw it. * * Every field is a measurement, never an estimate — with one stated exception * noted on `usd`. A number this ledger cannot know is absent rather than zero: * zero is a claim, and "we did not measure that" is a different fact. */ export interface RecentSpend { /** Turns admitted for this caller inside the window. */ readonly turns: number; /** Prompt tokens billed to this caller's runs, summed from `stream.llm_end`. */ readonly inputTokens: number; /** Completion tokens, from the same source. */ readonly outputTokens: number; /** * Estimated USD, summed from `cost.tick`. * * **Absent unless a `pricingTable` is configured on the agent** — money is * something only a pricing table can turn tokens into, and a zero here would * read as "this caller has spent nothing" when the truth is "nobody is * counting". Estimated, because the pricing table is a local table and the * invoice is the vendor's. */ readonly usd?: number; /** How far back this window looks, in milliseconds. */ readonly windowMs: number; /** * Whether the window is complete for this caller — `false` when the process * has been up for LESS than `windowMs`, so the numbers cover a shorter * period than they claim to. * * A policy that refuses on a partial window refuses on less evidence than it * thinks it has; one that ignores this is choosing to, which is fine, but it * should be a choice. */ readonly complete: boolean; } /** What an admission decision is handed. */ export interface AdmissionContext { /** * WHO is asking, when the door verified anybody. `undefined` for an * anonymous request and for a door with no verifier — never a claimed-but- * unproven user, because a policy that budgeted an unverified name would * budget whatever name the next request invented. */ readonly identity?: VerifiedIdentity; /** The conversation this request belongs to, when it named one. */ readonly sessionId?: string; /** This caller's rolling-window spend. See {@link RecentSpend}. */ readonly recentSpend: RecentSpend; } /** * What a policy answers. * * - `'allow'` — run it now (the shape of every request before this option * existed). * - `{ queue: true }` — run it, but behind whatever this session already has * in flight, even where the host would otherwise refuse a second concurrent * turn. A back-pressure valve that costs latency instead of a failure. * - `{ refuse }` — do not run it, and answer with this sentence. */ export type AdmissionVerdict = 'allow' | { readonly queue: true; } | { readonly refuse: string; }; /** The seam. One method, so a policy is a function with a name. */ export interface AdmissionPolicy { decide(context: AdmissionContext): AdmissionVerdict | Promise; } export interface TurnsPerHourOptions { /** How many turns one caller may start per hour. */ readonly limit: number; /** * What an ANONYMOUS caller may start per hour. Default: the same `limit`. * * Worth setting separately at a door built with `allowAnonymous`, where * every anonymous request shares one bucket: the shared bucket makes a * per-user limit into a global one for that lane, which is a fact rather * than a bug, and this is the dial for it. */ readonly anonymousLimit?: number; } /** * The reference policy: N turns per caller per hour, refused with the limit and * its reset in the sentence. * * It is deliberately the SIMPLEST honest rule — a count, a window, a sentence — * because its job is to show the shape rather than to be everybody's policy. * Read it and write yours: a real deployment's rule is usually "this plan, this * endpoint, this time of day", which is exactly the knowledge a library does * not have. * * @example * await standingAgent({ * agent, sessions, host, * identity: { verify }, // required for a PER-USER bound * admission: turnsPerHour({ limit: 60 }), * }); */ export declare function turnsPerHour(options: TurnsPerHourOptions): AdmissionPolicy; /** * The rolling-window ledger the composer feeds and a policy reads. * * Bounded twice, because an unbounded map keyed by caller-supplied strings is a * memory leak with an attacker's finger on it: entries older than the window * are dropped on every read, and the number of distinct callers held is capped * (the least-recently-touched is evicted). Evicting a caller costs that caller * a forgotten window — they are admitted where they might have been refused, * which is the safe direction for a bound whose alternative is falling over. */ export interface SpendLedger { /** Note that a turn was admitted for this caller. */ admit(key: string): void; /** Add measured usage to this caller's most recent turn. */ add(key: string, usage: { inputTokens?: number; outputTokens?: number; usd?: number; }): void; /** This caller's window, as of now. */ read(key: string): RecentSpend; /** How many distinct callers are currently held (diagnostic). */ readonly size: number; } export interface SpendLedgerOptions { /** Window length in ms. Default one hour — what `turnsPerHour` promises. */ readonly windowMs?: number; /** Distinct callers held before the least-recently-touched is evicted. */ readonly maxCallers?: number; /** @internal Test seam — the clock. */ readonly _now?: () => number; } export declare function spendLedger(options?: SpendLedgerOptions): SpendLedger; /** * The ledger key for one caller. * * A verified user is keyed by the id the TOKEN proved. Everyone else shares one * anonymous bucket — and sharing is the honest answer rather than a weakness: * there is no other fact to key on. A session id would let a caller mint a * fresh budget by asking for a new conversation, and an IP address is neither * available to this port nor a person. */ export declare function spendKeyFor(identity: VerifiedIdentity | undefined): string; //# sourceMappingURL=admission.d.ts.map