runcycles 0.1.0 → 0.1.1

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