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 +422 -96
- package/dist/index.cjs +137 -129
- package/dist/index.d.cts +2 -7
- package/dist/index.d.ts +2 -7
- package/dist/index.js +137 -128
- package/package.json +7 -2
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
|
-
|
|
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
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
183
|
+
### Which pattern to use?
|
|
187
184
|
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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}
|
|
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
|
-
|
|
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 (
|
|
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}
|
|
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
|
|
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
|
|
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
|
-
|
|
417
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
428
|
-
const ctx = getCyclesContext();
|
|
665
|
+
## Heartbeat
|
|
429
666
|
|
|
430
|
-
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
440
|
-
});
|
|
441
|
-
```
|
|
674
|
+
## Validation
|
|
442
675
|
|
|
443
|
-
|
|
676
|
+
The client validates inputs before sending requests:
|
|
444
677
|
|
|
445
|
-
|
|
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
|
|
482
|
-
- **Automatic heartbeat**: TTL extension
|
|
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`,
|
|
486
|
-
- **Zero dependencies**: Uses built-in `fetch` and `AsyncLocalStorage`
|
|
487
|
-
- **Response metadata**: Access `requestId`, `rateLimitRemaining`, `rateLimitReset`, and `cyclesTenant`
|
|
488
|
-
- **Environment config**: `CyclesConfig.fromEnv()`
|
|
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
|
-
##
|
|
818
|
+
## License
|
|
492
819
|
|
|
493
|
-
-
|
|
494
|
-
- TypeScript 5+ (for type definitions)
|
|
820
|
+
Apache-2.0
|