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 +431 -96
- package/dist/index.cjs +137 -119
- package/dist/index.d.cts +5 -4
- package/dist/index.d.ts +5 -4
- package/dist/index.js +137 -119
- package/package.json +8 -3
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
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
### From environment variables
|
|
192
|
+
### Which pattern to use?
|
|
189
193
|
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
194
|
-
// Reads: CYCLES_BASE_URL, CYCLES_API_KEY, CYCLES_TENANT, etc.
|
|
195
|
-
```
|
|
200
|
+
## Configuration
|
|
196
201
|
|
|
197
|
-
###
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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}
|
|
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
|
-
|
|
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 (
|
|
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}
|
|
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
|
|
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
|
|
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
|
-
|
|
417
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
428
|
-
const ctx = getCyclesContext();
|
|
674
|
+
## Heartbeat
|
|
429
675
|
|
|
430
|
-
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
440
|
-
});
|
|
441
|
-
```
|
|
683
|
+
## Validation
|
|
442
684
|
|
|
443
|
-
|
|
685
|
+
The client validates inputs before sending requests:
|
|
444
686
|
|
|
445
|
-
|
|
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
|
|
482
|
-
- **Automatic heartbeat**: TTL extension
|
|
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`,
|
|
486
|
-
- **Zero dependencies**: Uses built-in `fetch` and `AsyncLocalStorage`
|
|
487
|
-
- **Response metadata**: Access `requestId`, `rateLimitRemaining`, `rateLimitReset`, and `cyclesTenant`
|
|
488
|
-
- **Environment config**: `CyclesConfig.fromEnv()`
|
|
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
|
-
##
|
|
827
|
+
## License
|
|
492
828
|
|
|
493
|
-
-
|
|
494
|
-
- TypeScript 5+ (for type definitions)
|
|
829
|
+
Apache-2.0
|