runcycles 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,13 @@
1
1
  # Cycles TypeScript Client
2
2
 
3
- TypeScript client for the [Cycles](https://runcycles.io) budget-management protocol.
3
+ TypeScript client for the [Cycles](https://runcycles.io) budget-management protocol — govern spend on AI calls, API usage, and any metered resource.
4
+
5
+ Cycles lets you set budgets, reserve capacity before expensive operations, and track actual usage. This client handles the full reservation lifecycle: reserve budget up front, execute your work, then commit or release — with automatic heartbeats, retries, and typed error handling.
6
+
7
+ ## Requirements
8
+
9
+ - **Node.js 20+** (uses built-in `fetch` and `AsyncLocalStorage`)
10
+ - **TypeScript 5+** (for type definitions; optional — works with plain JavaScript)
4
11
 
5
12
  ## Installation
6
13
 
@@ -10,7 +17,9 @@ npm install runcycles
10
17
 
11
18
  ## Quick Start
12
19
 
13
- ### Higher-order function (recommended)
20
+ ### 1. Higher-order function (recommended)
21
+
22
+ Wrap any async function with `withCycles` to automatically reserve, execute, and commit:
14
23
 
15
24
  ```typescript
16
25
  import { CyclesClient, CyclesConfig, withCycles, getCyclesContext } from "runcycles";
@@ -49,7 +58,18 @@ const callLlm = withCycles(
49
58
  const result = await callLlm("Hello", 100);
50
59
  ```
51
60
 
52
- ### Streaming adapter
61
+ > **Need an API key?** API keys are created via the Cycles Admin Server (port 7979). See the [deployment guide](https://runcycles.io/quickstart/deploying-the-full-cycles-stack#step-3-create-an-api-key) to create one, or run:
62
+ > ```bash
63
+ > curl -s -X POST http://localhost:7979/v1/admin/api-keys \
64
+ > -H "Content-Type: application/json" \
65
+ > -H "X-Admin-API-Key: admin-bootstrap-key" \
66
+ > -d '{"tenant_id":"acme-corp","name":"dev-key","permissions":["reservations:create","reservations:commit","reservations:release","reservations:extend","reservations:list","balances:read","decide","events:create"]}' | jq -r '.key_secret'
67
+ > ```
68
+ > The key (e.g. `cyc_live_abc123...`) is shown only once — save it immediately. For key rotation and lifecycle details, see [API Key Management](https://runcycles.io/how-to/api-key-management-in-cycles).
69
+
70
+ **What happens:** `withCycles` reserves budget before calling your function, runs it inside an async context (so `getCyclesContext()` works), commits the actual cost on success, or releases the reservation on failure. A background heartbeat keeps the reservation alive.
71
+
72
+ ### 2. Streaming adapter
53
73
 
54
74
  For LLM streaming where usage is only known after the stream finishes:
55
75
 
@@ -100,29 +120,15 @@ try {
100
120
  }
101
121
  ```
102
122
 
103
- The handle owns its finalization: `commit()` and `release()` automatically stop the heartbeat.
104
- There is no need for a `finally { handle.dispose() }` block in a streaming handler, the
105
- `finally` would run when the handler returns the response object, not when the stream ends.
106
-
107
- The handle is **once-only and race-safe**: in real streaming code, multiple terminal paths
108
- can fire (onFinish, error, abort signal, client disconnect). Only the first terminal call wins:
109
- - `commit()` throws `CyclesError` if already finalized (dropping a commit silently hides bugs)
123
+ The handle is **once-only and race-safe**: in streaming code, multiple terminal paths can fire concurrently (onFinish, error handler, abort signal). Only the first terminal call wins:
124
+ - `commit()` throws `CyclesError` if already finalized (dropping a commit silently hides bugs). If `commit()` fails due to a network or server error, `finalized` resets to `false` so you can retry — but the heartbeat is **not** restarted (restart it manually if needed)
110
125
  - `release()` is a silent no-op if already finalized (best-effort by design)
126
+ - `dispose()` stops the heartbeat only, for startup failures before streaming begins
111
127
  - `handle.finalized` — check whether the handle has been finalized
112
128
 
113
- The `StreamReservation` handle provides:
114
- - `handle.commit(actual, metrics?, metadata?)` — commit actual usage and stop heartbeat (throws if finalized)
115
- - `handle.release(reason?)` — release reservation and stop heartbeat (no-op if finalized)
116
- - `handle.dispose()` — stop heartbeat only, for startup failures (no-op if finalized)
117
- - `handle.finalized` — true after any terminal call
118
- - `handle.reservationId` — the reservation ID
119
- - `handle.decision` — the budget decision (ALLOW or ALLOW_WITH_CAPS)
120
- - `handle.caps` — soft-landing caps, if any
121
-
122
- ### Programmatic client
129
+ ### 3. Programmatic client
123
130
 
124
- The client sends wire-format (snake_case) request bodies and returns wire-format responses.
125
- Use the typed mappers to convert between camelCase TypeScript interfaces and wire format:
131
+ Use `CyclesClient` directly for full control. The client operates on wire-format (snake_case) JSON. Use typed mappers for camelCase convenience, or pass raw snake_case objects:
126
132
 
127
133
  ```typescript
128
134
  import {
@@ -183,39 +189,77 @@ const response = await client.createReservation({
183
189
  });
184
190
  ```
185
191
 
186
- ## Configuration
187
-
188
- ### From environment variables
192
+ ### Which pattern to use?
189
193
 
190
- ```typescript
191
- import { CyclesConfig } from "runcycles";
194
+ | Pattern | Use when |
195
+ |---------|----------|
196
+ | `withCycles` | You have an async function that returns a result — the lifecycle is fully automatic |
197
+ | `reserveForStream` | You're streaming (e.g., LLM streaming) and usage is known only after the stream finishes |
198
+ | `CyclesClient` | You need full control over the reservation lifecycle, or are building custom integrations |
192
199
 
193
- const config = CyclesConfig.fromEnv();
194
- // Reads: CYCLES_BASE_URL, CYCLES_API_KEY, CYCLES_TENANT, etc.
195
- ```
200
+ ## Configuration
196
201
 
197
- ### All options
202
+ ### Constructor options
198
203
 
199
204
  ```typescript
200
205
  new CyclesConfig({
206
+ // Required
201
207
  baseUrl: "http://localhost:7878",
202
208
  apiKey: "your-api-key",
209
+
210
+ // Default subject fields (applied to all requests unless overridden)
203
211
  tenant: "acme",
204
212
  workspace: "prod",
205
213
  app: "chat",
206
214
  workflow: "refund-flow",
207
215
  agent: "planner",
208
216
  toolset: "search-tools",
209
- connectTimeout: 2_000, // ms
210
- readTimeout: 5_000, // ms
211
- retryEnabled: true,
212
- retryMaxAttempts: 5,
213
- retryInitialDelay: 500, // ms
214
- retryMultiplier: 2.0,
215
- retryMaxDelay: 30_000, // ms
217
+
218
+ // Timeouts (ms) — summed into a single fetch AbortSignal timeout
219
+ connectTimeout: 2_000, // default: 2000
220
+ readTimeout: 5_000, // default: 5000
221
+
222
+ // Commit retry (exponential backoff for failed commits)
223
+ retryEnabled: true, // default: true
224
+ retryMaxAttempts: 5, // default: 5
225
+ retryInitialDelay: 500, // default: 500 (ms)
226
+ retryMultiplier: 2.0, // default: 2.0
227
+ retryMaxDelay: 30_000, // default: 30000 (ms)
216
228
  });
217
229
  ```
218
230
 
231
+ > **Timeout note:** Node's built-in `fetch` does not distinguish connection timeout from read timeout. `connectTimeout` and `readTimeout` are summed into a single `AbortSignal.timeout()` value (default: 7000ms total) that caps the entire request duration.
232
+
233
+ ### Environment variables
234
+
235
+ ```typescript
236
+ import { CyclesConfig } from "runcycles";
237
+
238
+ const config = CyclesConfig.fromEnv();
239
+ ```
240
+
241
+ `fromEnv()` reads these environment variables (all prefixed with `CYCLES_` by default):
242
+
243
+ | Variable | Required | Description |
244
+ |----------|----------|-------------|
245
+ | `CYCLES_BASE_URL` | Yes | Cycles server URL |
246
+ | `CYCLES_API_KEY` | Yes | API key for authentication (see [how to create one](https://runcycles.io/quickstart/deploying-the-full-cycles-stack#step-3-create-an-api-key)) |
247
+ | `CYCLES_TENANT` | No | Default tenant |
248
+ | `CYCLES_WORKSPACE` | No | Default workspace |
249
+ | `CYCLES_APP` | No | Default app |
250
+ | `CYCLES_WORKFLOW` | No | Default workflow |
251
+ | `CYCLES_AGENT` | No | Default agent |
252
+ | `CYCLES_TOOLSET` | No | Default toolset |
253
+ | `CYCLES_CONNECT_TIMEOUT` | No | Connect timeout in ms (default: 2000) |
254
+ | `CYCLES_READ_TIMEOUT` | No | Read timeout in ms (default: 5000) |
255
+ | `CYCLES_RETRY_ENABLED` | No | Enable commit retry (default: true) |
256
+ | `CYCLES_RETRY_MAX_ATTEMPTS` | No | Max retry attempts (default: 5) |
257
+ | `CYCLES_RETRY_INITIAL_DELAY` | No | Initial retry delay in ms (default: 500) |
258
+ | `CYCLES_RETRY_MULTIPLIER` | No | Backoff multiplier (default: 2.0) |
259
+ | `CYCLES_RETRY_MAX_DELAY` | No | Max retry delay in ms (default: 30000) |
260
+
261
+ Custom prefix: `CyclesConfig.fromEnv("MYAPP_")` reads `MYAPP_BASE_URL`, `MYAPP_API_KEY`, etc.
262
+
219
263
  ### Default client / config
220
264
 
221
265
  Instead of passing `client` to every `withCycles` call, set a module-level default:
@@ -233,28 +277,97 @@ setDefaultClient(new CyclesClient(new CyclesConfig({ baseUrl: "http://localhost:
233
277
  const guarded = withCycles({ estimate: 1000 }, async () => "hello");
234
278
  ```
235
279
 
236
- ## Error handling
280
+ Client resolution is deferred to the first invocation and then cached — the wrapper binds permanently to the resolved client after its first call. A later `setDefaultClient()` call will not affect already-invoked wrappers.
237
281
 
238
- ```typescript
239
- const response = await client.createReservation({
240
- idempotency_key: "req-002",
241
- subject: { tenant: "acme" },
242
- action: { kind: "llm.completion", name: "gpt-4" },
243
- estimate: { unit: "USD_MICROCENTS", amount: 500_000 },
244
- });
282
+ ## `withCycles` Options
245
283
 
246
- if (response.isTransportError) {
247
- console.log(`Transport error: ${response.errorMessage}`);
248
- } else if (!response.isSuccess) {
249
- console.log(`Error ${response.status}: ${response.errorMessage}`);
250
- console.log(`Request ID: ${response.requestId}`);
284
+ The `WithCyclesConfig` interface controls the lifecycle behavior:
285
+
286
+ ```typescript
287
+ interface WithCyclesConfig {
288
+ // Cost estimation — required
289
+ estimate: number | ((...args) => number); // Estimated cost (static or computed from args)
290
+
291
+ // Actual cost — optional (defaults to estimate if not provided)
292
+ actual?: number | ((result) => number); // Actual cost (static or computed from result)
293
+ useEstimateIfActualNotProvided?: boolean; // Default: true — use estimate as actual
294
+
295
+ // Action identification
296
+ actionKind?: string; // e.g. "llm.completion" (default: "unknown")
297
+ actionName?: string; // e.g. "gpt-4" (default: "unknown")
298
+ actionTags?: string[]; // Optional tags for categorization
299
+
300
+ // Budget unit
301
+ unit?: string; // default: "USD_MICROCENTS"
302
+
303
+ // Reservation settings
304
+ ttlMs?: number; // Time-to-live in ms (default: 60000, range: 1000–86400000)
305
+ gracePeriodMs?: number; // Grace period in ms (range: 0–60000)
306
+ overagePolicy?: string; // "REJECT" (default), "ALLOW_IF_AVAILABLE", "ALLOW_WITH_OVERDRAFT"
307
+ dryRun?: boolean; // Shadow mode — evaluates budget without executing
308
+
309
+ // Subject fields (override config defaults)
310
+ tenant?: string;
311
+ workspace?: string;
312
+ app?: string;
313
+ workflow?: string;
314
+ agent?: string;
315
+ toolset?: string;
316
+ dimensions?: Record<string, string>; // Custom key-value dimensions
317
+
318
+ // Client
319
+ client?: CyclesClient; // Override the default client
251
320
  }
252
321
  ```
253
322
 
254
- With `withCycles` or `reserveForStream`, protocol errors are thrown as typed exceptions:
323
+ ## Context Access
324
+
325
+ Inside a `withCycles`-guarded function, access the active reservation via `getCyclesContext()`:
326
+
327
+ ```typescript
328
+ import { getCyclesContext } from "runcycles";
329
+
330
+ const guarded = withCycles({ estimate: 1000, client }, async () => {
331
+ const ctx = getCyclesContext();
332
+
333
+ // Read reservation details (read-only)
334
+ ctx?.reservationId; // Server-assigned reservation ID
335
+ ctx?.estimate; // The estimated amount
336
+ ctx?.decision; // "ALLOW" or "ALLOW_WITH_CAPS"
337
+ ctx?.caps; // Soft-landing caps (maxTokens, toolAllowlist, etc.)
338
+ ctx?.expiresAtMs; // Reservation expiry (updated by heartbeat)
339
+ ctx?.affectedScopes; // Budget scopes affected
340
+ ctx?.scopePath; // Scope path for this reservation
341
+ ctx?.reserved; // Amount reserved
342
+ ctx?.balances; // Balance snapshots
343
+
344
+ // Set metrics (included in the commit)
345
+ if (ctx) {
346
+ ctx.metrics = { tokensInput: 50, tokensOutput: 200, modelVersion: "gpt-4o" };
347
+ ctx.commitMetadata = { requestId: "abc", region: "us-east-1" };
348
+ }
349
+
350
+ return "result";
351
+ });
352
+ ```
353
+
354
+ The context uses `AsyncLocalStorage`, so it's available in any nested async call within the guarded function.
355
+
356
+ **Latency tracking:** If `ctx.metrics.latencyMs` is not set, `withCycles` automatically sets it to the execution time of the guarded function.
357
+
358
+ ## Error Handling
359
+
360
+ ### With `withCycles` or `reserveForStream`
361
+
362
+ Protocol errors are thrown as typed exceptions:
255
363
 
256
364
  ```typescript
257
- import { withCycles, BudgetExceededError, CyclesProtocolError } from "runcycles";
365
+ import {
366
+ withCycles,
367
+ BudgetExceededError,
368
+ CyclesProtocolError,
369
+ CyclesTransportError,
370
+ } from "runcycles";
258
371
 
259
372
  const guarded = withCycles({ estimate: 1000, client }, async () => "result");
260
373
 
@@ -264,18 +377,36 @@ try {
264
377
  if (err instanceof BudgetExceededError) {
265
378
  console.log("Budget exhausted — degrade or queue");
266
379
  } else if (err instanceof CyclesProtocolError) {
380
+ // Use helper methods for cleaner checks
381
+ if (err.isBudgetExceeded()) { /* ... */ }
382
+ if (err.isOverdraftLimitExceeded()) { /* ... */ }
383
+ if (err.isDebtOutstanding()) { /* ... */ }
384
+ if (err.isReservationExpired()) { /* ... */ }
385
+ if (err.isReservationFinalized()) { /* ... */ }
386
+ if (err.isIdempotencyMismatch()) { /* ... */ }
387
+ if (err.isUnitMismatch()) { /* ... */ }
388
+
389
+ // Retry handling
267
390
  if (err.isRetryable() && err.retryAfterMs) {
268
391
  console.log(`Retry after ${err.retryAfterMs}ms`);
269
392
  }
270
- console.log(`Protocol error: ${err.message}, code: ${err.errorCode}`);
393
+
394
+ // Error details
395
+ console.log(err.errorCode); // e.g. "BUDGET_EXCEEDED"
396
+ console.log(err.reasonCode); // Server-provided reason
397
+ console.log(err.requestId); // For support/debugging
398
+ console.log(err.details); // Additional error context
399
+ console.log(err.status); // HTTP status code
400
+ } else if (err instanceof CyclesTransportError) {
401
+ console.log("Network error:", err.message, err.cause);
271
402
  }
272
403
  }
273
404
  ```
274
405
 
275
- Exception hierarchy:
406
+ ### Exception hierarchy
276
407
 
277
408
  | Exception | When |
278
- |---|---|
409
+ |-----------|------|
279
410
  | `CyclesError` | Base for all Cycles errors |
280
411
  | `CyclesProtocolError` | Server returned a protocol-level error |
281
412
  | `BudgetExceededError` | Budget insufficient for the reservation |
@@ -285,7 +416,83 @@ Exception hierarchy:
285
416
  | `ReservationFinalizedError` | Operating on an already-committed/released reservation |
286
417
  | `CyclesTransportError` | Network-level failure (connection, DNS, timeout) |
287
418
 
288
- ## Preflight checks (decide)
419
+ ### With `CyclesClient` (programmatic)
420
+
421
+ The client returns `CyclesResponse` instead of throwing:
422
+
423
+ ```typescript
424
+ const response = await client.createReservation({ /* ... */ });
425
+
426
+ if (response.isTransportError) {
427
+ console.log("Network error:", response.errorMessage);
428
+ console.log("Underlying error:", response.transportError);
429
+ } else if (!response.isSuccess) {
430
+ console.log(`HTTP ${response.status}: ${response.errorMessage}`);
431
+ console.log(`Request ID: ${response.requestId}`);
432
+
433
+ // Parse structured error
434
+ const err = response.getErrorResponse();
435
+ if (err) {
436
+ console.log(`Error code: ${err.error}, Message: ${err.message}`);
437
+ console.log(`Details:`, err.details);
438
+ }
439
+ }
440
+ ```
441
+
442
+ ## Response Metadata
443
+
444
+ Every `CyclesResponse` exposes server headers:
445
+
446
+ ```typescript
447
+ const response = await client.createReservation({ /* ... */ });
448
+
449
+ response.requestId; // X-Request-Id — for tracing/debugging
450
+ response.rateLimitRemaining; // X-RateLimit-Remaining — requests left in window
451
+ response.rateLimitReset; // X-RateLimit-Reset — epoch seconds when window resets
452
+ response.cyclesTenant; // X-Cycles-Tenant — resolved tenant
453
+
454
+ // Status checks
455
+ response.isSuccess; // 2xx
456
+ response.isClientError; // 4xx
457
+ response.isServerError; // 5xx
458
+ response.isTransportError; // Network failure (status = -1)
459
+ ```
460
+
461
+ ## API Reference
462
+
463
+ ### `CyclesClient` Methods
464
+
465
+ All methods return `Promise<CyclesResponse>`.
466
+
467
+ | Method | Description |
468
+ |--------|-------------|
469
+ | `createReservation(request)` | Reserve budget before an operation |
470
+ | `commitReservation(reservationId, request)` | Commit actual usage after completion |
471
+ | `releaseReservation(reservationId, request)` | Release unused reservation |
472
+ | `extendReservation(reservationId, request)` | Extend reservation TTL (heartbeat) |
473
+ | `decide(request)` | Preflight budget check without creating a reservation |
474
+ | `createEvent(request)` | Record spend directly without a reservation (direct debit) |
475
+ | `listReservations(params?)` | List reservations with optional filters |
476
+ | `getReservation(reservationId)` | Get a single reservation's details |
477
+ | `getBalances(params)` | Query budget balances (requires at least one subject filter) |
478
+
479
+ ### `StreamReservation` Handle
480
+
481
+ Returned by `reserveForStream()`:
482
+
483
+ | Property/Method | Description |
484
+ |-----------------|-------------|
485
+ | `reservationId` | Server-assigned reservation ID |
486
+ | `decision` | Budget decision (`ALLOW` or `ALLOW_WITH_CAPS`) |
487
+ | `caps` | Soft-landing caps, if any |
488
+ | `finalized` | `true` after any terminal call |
489
+ | `commit(actual, metrics?, metadata?)` | Commit actual usage; throws if already finalized |
490
+ | `release(reason?)` | Release reservation; no-op if already finalized |
491
+ | `dispose()` | Stop heartbeat only, for startup failures |
492
+
493
+ ## Preflight Checks (decide)
494
+
495
+ Check if a budget would allow an operation without creating a reservation:
289
496
 
290
497
  ```typescript
291
498
  import { decisionRequestToWire, decisionResponseFromWire } from "runcycles";
@@ -308,7 +515,9 @@ if (response.isSuccess) {
308
515
  }
309
516
  ```
310
517
 
311
- ## Events (direct debit)
518
+ Use `decide()` for lightweight checks before committing to work (e.g., showing a user "you have budget remaining" in a UI), or when you want to inspect caps before starting. Unlike `createReservation`, it doesn't hold any budget.
519
+
520
+ ## Events (Direct Debit)
312
521
 
313
522
  Record spend without a prior reservation (returns HTTP 201):
314
523
 
@@ -323,6 +532,8 @@ const response = await client.createEvent(
323
532
  actual: { unit: "USD_MICROCENTS", amount: 1_500 },
324
533
  overagePolicy: "ALLOW_IF_AVAILABLE",
325
534
  metrics: { latencyMs: 120 },
535
+ clientTimeMs: Date.now(),
536
+ metadata: { region: "us-east-1" },
326
537
  }),
327
538
  );
328
539
 
@@ -332,11 +543,13 @@ if (response.isSuccess) {
332
543
  }
333
544
  ```
334
545
 
546
+ Use events for fast, low-value operations where the reserve/commit overhead isn't justified (e.g., simple API calls, cache lookups, tool invocations with known costs).
547
+
335
548
  ## Querying
336
549
 
337
550
  ### Balances
338
551
 
339
- At least one subject filter (`tenant`, `workspace`, `app`, `workflow`, `agent`, or `toolset`) is required:
552
+ At least one subject filter is required:
340
553
 
341
554
  ```typescript
342
555
  import { balanceResponseFromWire } from "runcycles";
@@ -345,40 +558,52 @@ const response = await client.getBalances({ tenant: "acme" });
345
558
  if (response.isSuccess) {
346
559
  const parsed = balanceResponseFromWire(response.body!);
347
560
  for (const balance of parsed.balances) {
348
- console.log(`${balance.scopePath}: remaining=${balance.remaining.amount}, spent=${balance.spent?.amount}`);
561
+ console.log(`${balance.scopePath}: remaining=${balance.remaining.amount}`);
562
+ console.log(` reserved=${balance.reserved?.amount}, spent=${balance.spent?.amount}`);
563
+ console.log(` allocated=${balance.allocated?.amount}`);
349
564
  if (balance.isOverLimit) {
350
565
  console.log(` OVER LIMIT — debt: ${balance.debt?.amount}, limit: ${balance.overdraftLimit?.amount}`);
351
566
  }
352
567
  }
568
+ // Pagination
353
569
  if (parsed.hasMore) {
354
- // Fetch next page with: client.getBalances({ tenant: "acme", cursor: parsed.nextCursor })
570
+ const next = await client.getBalances({ tenant: "acme", cursor: parsed.nextCursor! });
571
+ // ...
355
572
  }
356
573
  }
357
574
  ```
358
575
 
576
+ Query filters: `tenant`, `workspace`, `app`, `workflow`, `agent`, `toolset`, `include_children`, `limit`, `cursor`.
577
+
359
578
  ### Reservations
360
579
 
361
580
  ```typescript
362
581
  import { reservationListResponseFromWire, reservationDetailFromWire } from "runcycles";
363
582
 
364
- // List reservations (supports filters: tenant, workspace, app, workflow, agent, toolset, status, idempotency_key)
583
+ // List reservations (filters: tenant, workspace, app, workflow, agent, toolset, status, idempotency_key, limit, cursor)
365
584
  const list = await client.listReservations({ tenant: "acme", status: "ACTIVE" });
366
585
  if (list.isSuccess) {
367
586
  const parsed = reservationListResponseFromWire(list.body!);
368
587
  for (const r of parsed.reservations) {
369
588
  console.log(`${r.reservationId}: ${r.status} — ${r.reserved.amount} ${r.reserved.unit}`);
370
589
  }
590
+ if (parsed.hasMore) {
591
+ const next = await client.listReservations({ tenant: "acme", cursor: parsed.nextCursor! });
592
+ }
371
593
  }
372
594
 
373
595
  // Get a specific reservation
374
596
  const detail = await client.getReservation("r-123");
375
597
  if (detail.isSuccess) {
376
598
  const parsed = reservationDetailFromWire(detail.body!);
377
- console.log(`Status: ${parsed.status}, Committed: ${parsed.committed?.amount}`);
599
+ console.log(`Status: ${parsed.status}`);
600
+ console.log(`Reserved: ${parsed.reserved.amount}, Committed: ${parsed.committed?.amount}`);
601
+ console.log(`Created: ${parsed.createdAtMs}, Expires: ${parsed.expiresAtMs}`);
602
+ console.log(`Finalized: ${parsed.finalizedAtMs}`);
378
603
  }
379
604
  ```
380
605
 
381
- ### Release and extend
606
+ ### Release and Extend
382
607
 
383
608
  ```typescript
384
609
  import { releaseRequestToWire, releaseResponseFromWire } from "runcycles";
@@ -405,49 +630,76 @@ if (extendResp.isSuccess) {
405
630
  }
406
631
  ```
407
632
 
408
- ## Dry run (shadow mode)
633
+ ## Dry Run (Shadow Mode)
634
+
635
+ Test budget evaluation without executing the guarded function:
409
636
 
410
637
  ```typescript
638
+ import type { DryRunResult } from "runcycles";
639
+
411
640
  const guarded = withCycles(
412
641
  { estimate: 1000, dryRun: true, client },
413
642
  async () => "result",
414
643
  );
415
644
 
416
- // In dry-run mode, the function does not execute — a DryRunResult is returned instead.
417
- const dryResult = await guarded();
645
+ const dryResult = await guarded() as unknown as DryRunResult;
646
+ console.log(dryResult.decision); // "ALLOW", "ALLOW_WITH_CAPS", or throws on "DENY"
647
+ console.log(dryResult.caps); // Caps if ALLOW_WITH_CAPS
648
+ console.log(dryResult.reserved); // Amount that would be reserved
649
+ console.log(dryResult.affectedScopes);
650
+ console.log(dryResult.balances);
418
651
  ```
419
652
 
420
- ## Context access
653
+ ## Retry Behavior
654
+
655
+ When a commit fails due to a transport error or server error (5xx), the client automatically schedules background retries using exponential backoff:
421
656
 
422
- Inside a `withCycles`-guarded function, access the active reservation context:
657
+ - **Retries are fire-and-forget** — your guarded function returns immediately; the commit is retried in the background
658
+ - **Backoff formula:** `min(initialDelay * multiplier^attempt, maxDelay)` — defaults to 500ms, 1s, 2s, 4s, 8s
659
+ - **Non-retryable errors** (4xx client errors) stop retries immediately
660
+ - **Already-finalized reservations** (`RESERVATION_FINALIZED`, `RESERVATION_EXPIRED`) are accepted silently
661
+ - Retries only apply to commits from `withCycles` — the streaming adapter and programmatic client do not auto-retry
662
+
663
+ Configure via `CyclesConfig`:
423
664
 
424
665
  ```typescript
425
- import { getCyclesContext } from "runcycles";
666
+ new CyclesConfig({
667
+ // ...
668
+ retryEnabled: false, // disable retries entirely
669
+ retryMaxAttempts: 3, // fewer attempts
670
+ retryInitialDelay: 1000, // start slower
671
+ });
672
+ ```
426
673
 
427
- const guarded = withCycles({ estimate: 1000, client }, async () => {
428
- const ctx = getCyclesContext();
674
+ ## Heartbeat
429
675
 
430
- // Read reservation details
431
- console.log(ctx?.reservationId, ctx?.decision, ctx?.caps);
676
+ Both `withCycles` and `reserveForStream` start an automatic heartbeat that extends the reservation TTL while your work runs:
432
677
 
433
- // Set metrics (included in the commit)
434
- if (ctx) {
435
- ctx.metrics = { tokensInput: 50, tokensOutput: 200, modelVersion: "gpt-4o" };
436
- ctx.commitMetadata = { requestId: "abc" };
437
- }
678
+ - **Interval:** `max(ttlMs / 2, 1000ms)` — e.g., a 60s TTL heartbeats every 30s
679
+ - **Extension amount:** equals the full `ttlMs` each time
680
+ - **Best-effort:** heartbeat failures are silently ignored
681
+ - **Auto-stop:** the heartbeat stops when the reservation is committed, released, or disposed
438
682
 
439
- return "result";
440
- });
441
- ```
683
+ ## Validation
442
684
 
443
- ## Wire-format mappers
685
+ The client validates inputs before sending requests:
444
686
 
445
- The client operates on snake_case wire-format JSON. Typed mappers convert between camelCase TypeScript interfaces and the wire format, so you can choose your preferred style:
687
+ | Field | Constraint | Error |
688
+ |-------|-----------|-------|
689
+ | `subject` | At least one of: tenant, workspace, app, workflow, agent, toolset | `"Subject must have at least one standard field (tenant, workspace, app, workflow, agent, or toolset)"` |
690
+ | `estimate` | Must be >= 0 | `"estimate must be non-negative"` |
691
+ | `ttlMs` | 1,000 – 86,400,000 ms (1s – 24h) | `"ttl_ms must be between 1000 and 86400000"` |
692
+ | `gracePeriodMs` | 0 – 60,000 ms (0 – 60s) | `"grace_period_ms must be between 0 and 60000"` |
693
+ | `extendByMs` | 1 – 86,400,000 ms | `"extend_by_ms must be between 1 and 86400000"` |
694
+
695
+ ## Wire-Format Mappers
696
+
697
+ The client sends snake_case JSON on the wire. Typed mappers convert between camelCase TypeScript interfaces and wire format. Use `*ToWire()` when building requests and `*FromWire()` when parsing responses.
446
698
 
447
699
  ### Request mappers (camelCase → snake_case)
448
700
 
449
701
  | Mapper | Converts |
450
- |---|---|
702
+ |--------|----------|
451
703
  | `reservationCreateRequestToWire(req)` | `ReservationCreateRequest` → wire body |
452
704
  | `commitRequestToWire(req)` | `CommitRequest` → wire body |
453
705
  | `releaseRequestToWire(req)` | `ReleaseRequest` → wire body |
@@ -459,7 +711,7 @@ The client operates on snake_case wire-format JSON. Typed mappers convert betwee
459
711
  ### Response mappers (snake_case → camelCase)
460
712
 
461
713
  | Mapper | Returns |
462
- |---|---|
714
+ |--------|---------|
463
715
  | `reservationCreateResponseFromWire(wire)` | `ReservationCreateResponse` |
464
716
  | `commitResponseFromWire(wire)` | `CommitResponse` |
465
717
  | `releaseResponseFromWire(wire)` | `ReleaseResponse` |
@@ -473,22 +725,105 @@ The client operates on snake_case wire-format JSON. Typed mappers convert betwee
473
725
  | `errorResponseFromWire(wire)` | `ErrorResponse \| undefined` |
474
726
  | `capsFromWire(wire)` | `Caps \| undefined` |
475
727
 
728
+ ## Helper Functions
729
+
730
+ ```typescript
731
+ import {
732
+ isAllowed,
733
+ isDenied,
734
+ isRetryableErrorCode,
735
+ errorCodeFromString,
736
+ isToolAllowed,
737
+ isMetricsEmpty,
738
+ } from "runcycles";
739
+
740
+ // Decision helpers
741
+ isAllowed(decision); // true for ALLOW or ALLOW_WITH_CAPS
742
+ isDenied(decision); // true for DENY
743
+
744
+ // Error code helpers
745
+ isRetryableErrorCode(errorCode); // true for INTERNAL_ERROR or UNKNOWN
746
+ errorCodeFromString("BUDGET_EXCEEDED"); // ErrorCode.BUDGET_EXCEEDED (or UNKNOWN for unrecognized)
747
+
748
+ // Caps helpers — check if a tool is allowed given the caps
749
+ isToolAllowed(caps, "web_search"); // checks toolAllowlist/toolDenylist
750
+
751
+ // Metrics helpers
752
+ isMetricsEmpty(metrics); // true if all fields are undefined
753
+ ```
754
+
755
+ ## Enums
756
+
757
+ ```typescript
758
+ import { Unit, Decision, CommitOveragePolicy, ReservationStatus, ErrorCode } from "runcycles";
759
+
760
+ // Budget units
761
+ Unit.USD_MICROCENTS // 1 USD = 100_000_000 microcents
762
+ Unit.TOKENS
763
+ Unit.CREDITS
764
+ Unit.RISK_POINTS
765
+
766
+ // Budget decisions
767
+ Decision.ALLOW // Full budget available
768
+ Decision.ALLOW_WITH_CAPS // Allowed with soft-landing caps
769
+ Decision.DENY // Budget exhausted
770
+
771
+ // Overage policies (for commit and events)
772
+ CommitOveragePolicy.REJECT // Reject if over budget
773
+ CommitOveragePolicy.ALLOW_IF_AVAILABLE // Allow up to remaining budget
774
+ CommitOveragePolicy.ALLOW_WITH_OVERDRAFT // Allow with overdraft (creates debt)
775
+
776
+ // Reservation statuses
777
+ ReservationStatus.ACTIVE
778
+ ReservationStatus.COMMITTED
779
+ ReservationStatus.RELEASED
780
+ ReservationStatus.EXPIRED
781
+
782
+ // Error codes
783
+ ErrorCode.BUDGET_EXCEEDED
784
+ ErrorCode.OVERDRAFT_LIMIT_EXCEEDED
785
+ ErrorCode.DEBT_OUTSTANDING
786
+ ErrorCode.RESERVATION_EXPIRED
787
+ ErrorCode.RESERVATION_FINALIZED
788
+ ErrorCode.IDEMPOTENCY_MISMATCH
789
+ ErrorCode.UNIT_MISMATCH
790
+ ErrorCode.INVALID_REQUEST
791
+ ErrorCode.UNAUTHORIZED
792
+ ErrorCode.FORBIDDEN
793
+ ErrorCode.NOT_FOUND
794
+ ErrorCode.INTERNAL_ERROR
795
+ ErrorCode.UNKNOWN
796
+ ```
797
+
798
+ ## Examples
799
+
800
+ See the [`examples/`](./examples/) directory:
801
+
802
+ - [`basic-usage.ts`](./examples/basic-usage.ts) — Programmatic client with full reserve/commit lifecycle
803
+ - [`async-usage.ts`](./examples/async-usage.ts) — `withCycles` with async/await and context access
804
+ - [`decorator-usage.ts`](./examples/decorator-usage.ts) — `withCycles` patterns
805
+ - [`vercel-ai-sdk/`](./examples/vercel-ai-sdk/) — Next.js + Vercel AI SDK streaming integration
806
+ - [`openai-sdk/`](./examples/openai-sdk/) — Direct OpenAI SDK with non-streaming and streaming patterns
807
+ - [`anthropic-sdk/`](./examples/anthropic-sdk/) — Anthropic Claude SDK with Caps-aware `max_tokens`
808
+ - [`langchain-js/`](./examples/langchain-js/) — LangChain.js chains and ReAct agents with Caps integration
809
+ - [`express-middleware/`](./examples/express-middleware/) — Reusable Express middleware for budget governance
810
+
476
811
  ## Features
477
812
 
478
813
  - **`withCycles` HOF**: Wraps async functions with automatic reserve/execute/commit lifecycle
479
814
  - **`reserveForStream`**: First-class streaming adapter — reserve before, heartbeat during, commit on finish
480
815
  - **Programmatic client**: Full control via `CyclesClient` with wire-format passthrough
481
- - **Typed wire-format mappers**: Convert between camelCase TypeScript and snake_case wire format for all request/response types
482
- - **Automatic heartbeat**: TTL extension at half-interval keeps reservations alive
483
- - **Commit retry**: Failed commits are retried with exponential backoff
816
+ - **Typed wire-format mappers**: Convert between camelCase TypeScript and snake_case wire format
817
+ - **Automatic heartbeat**: TTL extension keeps reservations alive during long operations
818
+ - **Commit retry**: Failed commits are retried with exponential backoff in the background
484
819
  - **Context access**: `getCyclesContext()` provides reservation details inside guarded functions
485
- - **Typed exceptions**: `BudgetExceededError`, `OverdraftLimitExceededError`, etc.
486
- - **Zero dependencies**: Uses built-in `fetch` and `AsyncLocalStorage`
487
- - **Response metadata**: Access `requestId`, `rateLimitRemaining`, `rateLimitReset`, and `cyclesTenant` on every response
488
- - **Environment config**: `CyclesConfig.fromEnv()` for 12-factor apps
820
+ - **Typed exceptions**: `BudgetExceededError`, `OverdraftLimitExceededError`, and more
821
+ - **Zero runtime dependencies**: Uses built-in `fetch` and `AsyncLocalStorage`
822
+ - **Response metadata**: Access `requestId`, `rateLimitRemaining`, `rateLimitReset`, and `cyclesTenant`
823
+ - **Environment config**: `CyclesConfig.fromEnv()` with custom prefix support
489
824
  - **Dual ESM/CJS**: Works with both module systems
825
+ - **Input validation**: Client-side validation of TTL, amounts, subject fields, and more
490
826
 
491
- ## Requirements
827
+ ## License
492
828
 
493
- - Node.js 20+
494
- - TypeScript 5+ (for type definitions)
829
+ Apache-2.0