nexus-ai-pro 1.5.0 → 1.6.0
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/API_STABILITY.md +27 -0
- package/CHANGELOG.md +57 -1
- package/README.md +94 -0
- package/ROADMAP.md +53 -22
- package/dist/images/manager.js +7 -121
- package/dist/index.d.ts +9 -0
- package/dist/index.js +8 -0
- package/dist/operations/adapters.d.ts +69 -0
- package/dist/operations/adapters.js +111 -0
- package/dist/operations/errors.d.ts +59 -0
- package/dist/operations/errors.js +99 -0
- package/dist/operations/handle.d.ts +75 -0
- package/dist/operations/handle.js +229 -0
- package/dist/operations/index.d.ts +9 -0
- package/dist/operations/index.js +8 -0
- package/dist/operations/runner.d.ts +72 -0
- package/dist/operations/runner.js +489 -0
- package/dist/operations/state-machine.d.ts +11 -0
- package/dist/operations/state-machine.js +47 -0
- package/dist/operations/store.d.ts +36 -0
- package/dist/operations/store.js +140 -0
- package/dist/operations/webhooks.d.ts +33 -0
- package/dist/operations/webhooks.js +84 -0
- package/dist/types/images.d.ts +5 -45
- package/dist/types/operations.d.ts +227 -0
- package/dist/types/operations.js +12 -0
- package/dist-cjs/images/manager.js +7 -121
- package/dist-cjs/index.d.ts +9 -0
- package/dist-cjs/index.js +58 -23
- package/dist-cjs/operations/adapters.d.ts +69 -0
- package/dist-cjs/operations/adapters.js +116 -0
- package/dist-cjs/operations/errors.d.ts +59 -0
- package/dist-cjs/operations/errors.js +110 -0
- package/dist-cjs/operations/handle.d.ts +75 -0
- package/dist-cjs/operations/handle.js +234 -0
- package/dist-cjs/operations/index.d.ts +9 -0
- package/dist-cjs/operations/index.js +37 -0
- package/dist-cjs/operations/runner.d.ts +72 -0
- package/dist-cjs/operations/runner.js +493 -0
- package/dist-cjs/operations/state-machine.d.ts +11 -0
- package/dist-cjs/operations/state-machine.js +55 -0
- package/dist-cjs/operations/store.d.ts +36 -0
- package/dist-cjs/operations/store.js +145 -0
- package/dist-cjs/operations/webhooks.d.ts +33 -0
- package/dist-cjs/operations/webhooks.js +90 -0
- package/dist-cjs/security/schema-validator.d.ts +24 -24
- package/dist-cjs/types/images.d.ts +5 -45
- package/dist-cjs/types/operations.d.ts +227 -0
- package/dist-cjs/types/operations.js +16 -0
- package/package.json +40 -1
package/API_STABILITY.md
CHANGED
|
@@ -131,6 +131,33 @@ and are overridable through `embeddings.models.registry`. Which concrete model a
|
|
|
131
131
|
not interchangeable, so pin a concrete model whenever a stored index must stay valid across
|
|
132
132
|
upgrades. Provider payloads under `raw` and reported token counts are controlled by the provider.
|
|
133
133
|
|
|
134
|
+
## Operations API stage
|
|
135
|
+
|
|
136
|
+
The `nexus-ai-pro/operations` family is public and follows the 1.x rules, including the
|
|
137
|
+
`operations/adapters` and `operations/webhooks` subpaths. This covers `OperationRunner`,
|
|
138
|
+
`LocalOperationHandle`, `MemoryOperationStore`, the `OperationStore` and `OperationDispatcher`
|
|
139
|
+
contracts, the Redis and BullMQ adapters, the webhook signing and verification helpers, the state
|
|
140
|
+
machine predicates, and the normalized record, event, and error shapes.
|
|
141
|
+
|
|
142
|
+
The lifecycle types moved out of `types/images.ts` and are re-exported from it, so existing image
|
|
143
|
+
imports of `OperationStatus`, `OperationEvent`, `OperationHandle`, `OperationEventBase`, and
|
|
144
|
+
`OperationErrorDescriptor` resolve unchanged. `OperationStatus` gained `retrying`, and
|
|
145
|
+
`OperationEvent` gained `progress` and `retrying` variants and an `attempt` field on `running` —
|
|
146
|
+
additive changes this policy permits in a minor release. Code that switches exhaustively on either
|
|
147
|
+
union should add the new members.
|
|
148
|
+
|
|
149
|
+
Three behaviors are guaranteed. Terminal statuses are final, so a late provider callback cannot
|
|
150
|
+
resurrect a settled operation. Store writes are compare-and-set on `sequence`, so a losing writer
|
|
151
|
+
is told it lost rather than silently overwriting. And a record carrying raw bytes is refused with
|
|
152
|
+
`OperationSerializationError` rather than persisted, because a base64 round trip through a queue
|
|
153
|
+
payload is a failure mode that otherwise appears only as a truncated job.
|
|
154
|
+
|
|
155
|
+
Durability is a property of the configured store, not of the runner. `MemoryOperationStore` does
|
|
156
|
+
not survive a restart and does not promise cross-process recovery. `RedisOperationStore` is atomic
|
|
157
|
+
only when the client exposes `eval`; without it the compare-and-set degrades to a read-compare-write
|
|
158
|
+
that narrows but does not close the race, and that limitation is documented rather than hidden.
|
|
159
|
+
Webhook delivery is best-effort and never fails the operation it describes.
|
|
160
|
+
|
|
134
161
|
## Deprecation process
|
|
135
162
|
|
|
136
163
|
Deprecated APIs are marked with `@deprecated` in declarations and described in the changelog. Removals
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,61 @@ Notable changes to this project are recorded here. The format follows [Keep a Ch
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.6.0] - 2026-09-03
|
|
8
|
+
|
|
9
|
+
Durable operations, the first half of the theme *work that outlives a process*. Long-running work now
|
|
10
|
+
runs through one lifecycle that survives a worker crash. Everything here is additive.
|
|
11
|
+
|
|
12
|
+
The rest of the theme — provider batch APIs, distributed rate limiting and circuit breaking,
|
|
13
|
+
filesystem and S3 asset stores, a generated model registry, and cross-family observability — moves to
|
|
14
|
+
1.7.0. The operation handle they all sit behind ships here, which is what unblocks them.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- **`OperationRunner` and a shared operation lifecycle.** `queued → running → succeeded | failed`,
|
|
19
|
+
with `retrying`, `cancelling`, `cancelled`, and `expired` covering the rest, exposed through the
|
|
20
|
+
new `nexus-ai-pro/operations` subpath. The runner persists a record before doing any work, claims
|
|
21
|
+
a lease, heartbeats while the executor runs, retries with backoff and jitter, dead-letters what
|
|
22
|
+
never succeeds, and emits signed webhooks.
|
|
23
|
+
- **Restart survival through leases rather than locks.** A worker that dies leaves a record whose
|
|
24
|
+
lease lapses; another worker's `recover()` sweep resumes it. Every store write is a compare-and-set
|
|
25
|
+
on the record's `sequence`, so two workers racing on one operation cannot both win, and the loser
|
|
26
|
+
is told it lost instead of silently overwriting.
|
|
27
|
+
- **`RedisOperationStore` and `BullMQOperationDispatcher`.** Redis persists records, with the
|
|
28
|
+
compare-and-set done in one Lua call when the client exposes `eval`. BullMQ dispatch queues the
|
|
29
|
+
operation id and its routing metadata only, never a payload.
|
|
30
|
+
- **Idempotency keys.** A key matching an existing record replays that operation instead of starting
|
|
31
|
+
a second one, so an ambiguous timeout cannot produce a duplicate charge.
|
|
32
|
+
- **Progress and cancellation.** An executor reports progress through `context.report()`, which
|
|
33
|
+
reaches both `handle.events()` and the persisted record. `runner.cancel()` settles an operation
|
|
34
|
+
owned by another worker, observed there through its heartbeat.
|
|
35
|
+
- **Signed webhooks, with the verifying half included.** Deliveries are HMAC-SHA256 over
|
|
36
|
+
`${timestamp}.${body}`, so a captured delivery cannot be replayed indefinitely.
|
|
37
|
+
`verifyOperationWebhook()` ships alongside `signOperationWebhook()` rather than leaving every
|
|
38
|
+
receiver to hand-roll a constant-time comparison. A failed delivery is reported and never fails
|
|
39
|
+
the operation it describes.
|
|
40
|
+
- **A binary-payload guard.** Persisting a result carrying a `Uint8Array`, `Buffer`, `Blob`, or
|
|
41
|
+
stream throws `OperationSerializationError` naming the exact path and pointing at `AssetStore`.
|
|
42
|
+
Base64 in a job payload inflates it by a third and most queue backends cap job size well below one
|
|
43
|
+
image, so this fails loudly at the boundary instead of appearing later as a truncated job.
|
|
44
|
+
- New subpaths `nexus-ai-pro/operations`, `operations/adapters`, and `operations/webhooks`.
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
|
|
48
|
+
- The operation lifecycle types moved from `types/images.ts` to a family-neutral `types/operations.ts`
|
|
49
|
+
and are re-exported from their old home, so existing image imports resolve unchanged. The image
|
|
50
|
+
family's private handle was replaced by the shared `LocalOperationHandle`, which is told to reject
|
|
51
|
+
with `ImageOperationCancelledError` so callers catching that error see no difference.
|
|
52
|
+
- `OperationStatus` gained `retrying`; `OperationEvent` gained `progress` and `retrying` variants and
|
|
53
|
+
an `attempt` field on `running`. Additive, but code switching exhaustively on either union should
|
|
54
|
+
add the new members.
|
|
55
|
+
|
|
56
|
+
### Fixed
|
|
57
|
+
|
|
58
|
+
- A handle driven by the runner stayed in `queued` for the whole run, so no `running` event was
|
|
59
|
+
emitted and every `context.report()` call was silently dropped. Found while testing the runner's
|
|
60
|
+
event sequence; the handle now exposes `markRunning()` and the runner calls it before each attempt.
|
|
61
|
+
|
|
7
62
|
## [1.5.0] - 2026-08-31
|
|
8
63
|
|
|
9
64
|
Embeddings become a first-class operation. `ai.embed()` gets the routing, caching, batching, budget,
|
|
@@ -266,7 +321,8 @@ shared policy vocabulary is in place for a future release that revisits this.
|
|
|
266
321
|
- URL fetching rejects unsafe private-network targets and limits response reads.
|
|
267
322
|
- CLI and audit findings no longer disclose detected secret values.
|
|
268
323
|
|
|
269
|
-
[Unreleased]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v1.
|
|
324
|
+
[Unreleased]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v1.6.0...HEAD
|
|
325
|
+
[1.6.0]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v1.5.0...v1.6.0
|
|
270
326
|
[1.5.0]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v1.4.0...v1.5.0
|
|
271
327
|
[1.4.0]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v1.3.0...v1.4.0
|
|
272
328
|
[1.3.0]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v1.2.1...v1.3.0
|
package/README.md
CHANGED
|
@@ -84,6 +84,7 @@ You can still pass a plain `NexusAIConfig` to `new NexusAI(...)` when you want f
|
|
|
84
84
|
- cache exact or semantically similar prompts
|
|
85
85
|
- add tools, agents, RAG context, evals, batch jobs, and queues
|
|
86
86
|
- embed text through the same routing, caching, batching, budget, retry, and metrics as a completion
|
|
87
|
+
- run long operations that survive a restart, with leases, retries, dead-lettering, and signed webhooks
|
|
87
88
|
- build persistent realtime voice agents with interruption, live tools, and normalized conversation state
|
|
88
89
|
- keep TypeScript types around every request and response
|
|
89
90
|
|
|
@@ -417,6 +418,96 @@ const response = await ai.completeVerified(
|
|
|
417
418
|
);
|
|
418
419
|
```
|
|
419
420
|
|
|
421
|
+
## Durable Operations
|
|
422
|
+
|
|
423
|
+
Long-running work — an image render, a batch, anything asynchronous — runs through one lifecycle:
|
|
424
|
+
`queued → running → succeeded | failed`, with `retrying`, `cancelling`, `cancelled`, and `expired`
|
|
425
|
+
covering the rest. `OperationRunner` owns it end to end, so a crashed worker does not lose work.
|
|
426
|
+
|
|
427
|
+
The default store is in-process, so the small case needs no infrastructure:
|
|
428
|
+
|
|
429
|
+
```ts
|
|
430
|
+
import { OperationRunner } from 'nexus-ai-pro/operations';
|
|
431
|
+
|
|
432
|
+
const runner = new OperationRunner<string>({ retry: { maxAttempts: 3, baseDelayMs: 500 } });
|
|
433
|
+
|
|
434
|
+
const handle = await runner.submit(async (context) => {
|
|
435
|
+
context.report({ completed: 1, total: 3 });
|
|
436
|
+
return doTheWork(context.signal);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
for await (const event of handle.events()) {
|
|
440
|
+
console.log(event.type, event.sequence);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const value = await handle.result();
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
Swapping the store makes the same code survive a restart. Nothing else changes:
|
|
447
|
+
|
|
448
|
+
```ts
|
|
449
|
+
import { OperationRunner } from 'nexus-ai-pro/operations';
|
|
450
|
+
import { BullMQOperationDispatcher, RedisOperationStore } from 'nexus-ai-pro/operations/adapters';
|
|
451
|
+
|
|
452
|
+
const runner = new OperationRunner({
|
|
453
|
+
store: new RedisOperationStore(redis),
|
|
454
|
+
dispatcher: new BullMQOperationDispatcher(queue),
|
|
455
|
+
owner: process.env.HOSTNAME,
|
|
456
|
+
leaseMs: 30_000,
|
|
457
|
+
webhook: { url: 'https://app.example/hooks/operations', secret: process.env.HOOK_SECRET! },
|
|
458
|
+
});
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
**How restart survival actually works.** A worker claims a lease before running and heartbeats
|
|
462
|
+
while it works. If the process dies, the lease simply lapses; another worker's `recover()` sweep
|
|
463
|
+
finds the record and resumes it. There is no distributed lock — every store write is a
|
|
464
|
+
compare-and-set on the record's `sequence`, so two workers racing on the same operation cannot both
|
|
465
|
+
win.
|
|
466
|
+
|
|
467
|
+
```ts
|
|
468
|
+
// On startup, in each worker.
|
|
469
|
+
const resumed = await runner.recover(executor);
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
Recovery is deliberately conservative: a record past its `expiresAt` is **expired** rather than
|
|
473
|
+
re-run, and one that has used every attempt is **dead-lettered** and parked for inspection, so a
|
|
474
|
+
permanently failing operation cannot be recovered forever.
|
|
475
|
+
|
|
476
|
+
**Idempotency.** An `idempotencyKey` that matches an existing record replays that operation instead
|
|
477
|
+
of starting a second one, which is what stops an ambiguous timeout from double-charging:
|
|
478
|
+
|
|
479
|
+
```ts
|
|
480
|
+
const handle = await runner.submit(chargeAndRender, { idempotencyKey: `render:${orderId}` });
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
**Webhooks** are signed with HMAC-SHA256 over `${timestamp}.${body}`, so a captured delivery cannot
|
|
484
|
+
be replayed indefinitely. The verifying half ships too — a receiver should never have to hand-roll a
|
|
485
|
+
constant-time comparison:
|
|
486
|
+
|
|
487
|
+
```ts
|
|
488
|
+
import { OPERATION_WEBHOOK_SIGNATURE_HEADER, verifyOperationWebhook } from 'nexus-ai-pro/operations/webhooks';
|
|
489
|
+
|
|
490
|
+
app.post('/hooks/operations', (request, response) => {
|
|
491
|
+
const ok = verifyOperationWebhook(
|
|
492
|
+
request.rawBody,
|
|
493
|
+
request.header(OPERATION_WEBHOOK_SIGNATURE_HEADER),
|
|
494
|
+
process.env.HOOK_SECRET!,
|
|
495
|
+
);
|
|
496
|
+
response.sendStatus(ok ? 204 : 400);
|
|
497
|
+
});
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
A failed delivery is reported through `onWebhookError` and never turns a completed operation into a
|
|
501
|
+
failed one.
|
|
502
|
+
|
|
503
|
+
**Binary payloads are refused, not truncated.** Persisting a result that carries a `Uint8Array`,
|
|
504
|
+
`Buffer`, or `Blob` throws `OperationSerializationError` naming the exact path. Base64 in a job
|
|
505
|
+
payload inflates it by a third and most queue backends cap job size well below one image, so the
|
|
506
|
+
bytes belong in an `AssetStore` with only a reference on the record. The BullMQ dispatcher likewise
|
|
507
|
+
queues the operation id and nothing else.
|
|
508
|
+
|
|
509
|
+
The image family already runs on this lifecycle, so `ai.images.submit()` reports the same events.
|
|
510
|
+
|
|
420
511
|
## Embeddings
|
|
421
512
|
|
|
422
513
|
`ai.embed()` is a first-class operation, not a helper: it gets the same routing, caching, batching,
|
|
@@ -1226,6 +1317,9 @@ import { negotiateCompletionRequest } from 'nexus-ai-pro/capabilities';
|
|
|
1226
1317
|
import { guardrailPolicy } from 'nexus-ai-pro/security';
|
|
1227
1318
|
import { RedisCacheAdapter } from 'nexus-ai-pro/cache';
|
|
1228
1319
|
import { DeepSeekProvider } from 'nexus-ai-pro/providers/deepseek';
|
|
1320
|
+
import { OperationRunner } from 'nexus-ai-pro/operations';
|
|
1321
|
+
import { RedisOperationStore } from 'nexus-ai-pro/operations/adapters';
|
|
1322
|
+
import { verifyOperationWebhook } from 'nexus-ai-pro/operations/webhooks';
|
|
1229
1323
|
import { EmbeddingManager } from 'nexus-ai-pro/embeddings';
|
|
1230
1324
|
import { OpenAIEmbeddingProvider } from 'nexus-ai-pro/embeddings/adapters';
|
|
1231
1325
|
import { MockEmbeddingProvider } from 'nexus-ai-pro/embeddings/mock';
|
package/ROADMAP.md
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
This roadmap is a design proposal, not a compatibility promise. Stable and experimental
|
|
4
4
|
surfaces are defined in [API_STABILITY.md](./API_STABILITY.md).
|
|
5
5
|
|
|
6
|
-
Status baseline: **1.
|
|
7
|
-
completion registry models plus 63 aliases, and 11 embedding models plus 5 aliases.
|
|
8
|
-
pass; coverage sits at **
|
|
6
|
+
Status baseline: **1.6.0**. 62 export subpaths, 12 completion providers, 5 embedding providers, 99
|
|
7
|
+
completion registry models plus 63 aliases, and 11 embedding models plus 5 aliases. 292 unit tests
|
|
8
|
+
pass; coverage sits at **86.3% lines / 71.6% branches / 81.4% functions** against gates of
|
|
9
9
|
82/67/73. CI verifies lint, format, build, tests, coverage, mock conformance, packed-package smoke,
|
|
10
10
|
API contract, consumer type resolution, and clean install on Node 22 and 24.
|
|
11
11
|
|
|
@@ -102,7 +102,16 @@ deduplication, per-input caching, cost budgets, retry, structured usage and nume
|
|
|
102
102
|
refusal rather than silent option dropping, `toEmbeddingFunction()` for existing vector stores, a
|
|
103
103
|
deterministic mock, and a conformance harness.
|
|
104
104
|
|
|
105
|
-
### 1.9
|
|
105
|
+
### 1.9 Durable operations
|
|
106
|
+
|
|
107
|
+
Delivered in 1.6.0. `OperationRunner` over an `OperationStore` contract, with a checked state
|
|
108
|
+
machine, leases and heartbeats, delayed retry with backoff and jitter, dead-lettering, progress
|
|
109
|
+
events, idempotency replay, crash recovery, and HMAC-signed webhooks with a matching verifier.
|
|
110
|
+
`MemoryOperationStore` keeps the single-process case dependency-free; `RedisOperationStore` and
|
|
111
|
+
`BullMQOperationDispatcher` make the same code survive a restart. Persisting raw bytes is refused
|
|
112
|
+
so binary media cannot end up in a queue payload.
|
|
113
|
+
|
|
114
|
+
### 1.10 Operations, evaluation, and packaging
|
|
106
115
|
|
|
107
116
|
Rate limiting, audit logging, in-memory and OpenTelemetry metrics sinks, Prometheus export, an
|
|
108
117
|
OpenTelemetry trace exporter, provider health monitoring, `EvalRunner` with LLM-as-judge and a
|
|
@@ -147,13 +156,17 @@ visible. The data itself is still a hand-written TypeScript literal of 99 models
|
|
|
147
156
|
Generation from versioned provider data remains outstanding, and the Claude 5 reasoning bug 1.4.0
|
|
148
157
|
fixed is the kind of error generation would have prevented.
|
|
149
158
|
|
|
150
|
-
### 2.4 Durable
|
|
159
|
+
### 2.4 Durable execution is only half distributed
|
|
160
|
+
|
|
161
|
+
Closed in 1.6.0 for operations: `OperationHandle` is no longer process-bound, cross-process recovery
|
|
162
|
+
works through lapsed leases, and idempotency keys deduplicate across workers.
|
|
151
163
|
|
|
152
|
-
`RateLimiter` is an in-memory token bucket with no distributed adapter
|
|
153
|
-
(health monitoring exists, but nothing opens a circuit)
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
164
|
+
Still process-local: `RateLimiter` is an in-memory token bucket with no distributed adapter, there is
|
|
165
|
+
no circuit breaker (health monitoring exists, but nothing opens a circuit), `MemoryAssetStore` holds
|
|
166
|
+
assets in one process, and realtime conversation snapshots and exports live in memory. Provider-state
|
|
167
|
+
replay remains an application responsibility. One caveat on what did land: `RedisOperationStore` is
|
|
168
|
+
atomic only when the client exposes `eval`; without it the compare-and-set degrades to a
|
|
169
|
+
read-compare-write that narrows but does not close the race.
|
|
157
170
|
|
|
158
171
|
### 2.5 Image family blockers
|
|
159
172
|
|
|
@@ -247,15 +260,33 @@ vector is silently incompatible with a populated store rather than merely differ
|
|
|
247
260
|
Deliberately deferred: the provider batch-API tier, which belongs with the durable operation handle
|
|
248
261
|
below rather than with the synchronous path.
|
|
249
262
|
|
|
250
|
-
## 5.
|
|
263
|
+
## 5. Shipped in 1.6.0 — durable operations
|
|
264
|
+
|
|
265
|
+
The first half of *work that outlives a process*. One lifecycle now covers every long-running
|
|
266
|
+
family, and it survives a worker crash.
|
|
267
|
+
|
|
268
|
+
- **Operation state machine.** `queued → running → retrying → succeeded | failed | cancelling |
|
|
269
|
+
cancelled | expired`, with leases, heartbeats, progress events, delayed retry, dead-letter
|
|
270
|
+
handling, timestamps, signed webhooks, and trace propagation. The image family runs on it, and the
|
|
271
|
+
lifecycle types moved to a family-neutral `types/operations.ts` re-exported from their old home.
|
|
272
|
+
- **Durable operation adapters.** `RedisOperationStore` persists records with a compare-and-set on
|
|
273
|
+
`sequence`, done in one Lua call when the client exposes `eval`. `BullMQOperationDispatcher`
|
|
274
|
+
queues the operation id only. A record carrying raw bytes is refused rather than serialized.
|
|
275
|
+
- **Idempotency and recovery.** A matching key replays an accepted operation instead of starting a
|
|
276
|
+
second; `recover()` resumes records whose lease lapsed, expiring those past their deadline and
|
|
277
|
+
dead-lettering those that used every attempt.
|
|
278
|
+
|
|
279
|
+
Restart survival comes from leases rather than locks. There is no distributed lock anywhere: every
|
|
280
|
+
store write is a compare-and-set, so a losing writer is told it lost instead of overwriting.
|
|
281
|
+
|
|
282
|
+
Deliberately deferred: everything below, which needed the operation handle to exist first.
|
|
283
|
+
|
|
284
|
+
---
|
|
285
|
+
|
|
286
|
+
## 6. Next release: 1.7.0 — batch economics and distributed limits
|
|
251
287
|
|
|
252
|
-
The theme
|
|
288
|
+
The rest of the theme, now that the handle they sit behind exists.
|
|
253
289
|
|
|
254
|
-
- **Operation state machine.** `queued → running → succeeded | failed | cancelling | cancelled | expired`,
|
|
255
|
-
with leases, heartbeats, progress events, delayed retry, dead-letter handling, timestamps, signed
|
|
256
|
-
webhooks, and trace propagation. Applies to images first, then to any long-running family.
|
|
257
|
-
- **Durable operation adapters.** Redis and BullMQ-backed handles so `submit()` survives a restart.
|
|
258
|
-
Queue asset references only; never JSON-serialize binary media into job payloads.
|
|
259
290
|
- **Provider batch APIs.** OpenAI Batch and Anthropic Message Batches behind the same operation
|
|
260
291
|
handle, exposing the discounted asynchronous tier that local `runBatch()` concurrency cannot reach.
|
|
261
292
|
Idempotency keys replay an accepted operation; ambiguous timeouts must not produce duplicate
|
|
@@ -273,7 +304,7 @@ The theme is work that outlives a process.
|
|
|
273
304
|
|
|
274
305
|
---
|
|
275
306
|
|
|
276
|
-
##
|
|
307
|
+
## 7. 1.8.0 — image portability, then promotion
|
|
277
308
|
|
|
278
309
|
Images cannot leave experimental until the neutral contract survives a second wire protocol.
|
|
279
310
|
|
|
@@ -301,7 +332,7 @@ label come off.
|
|
|
301
332
|
|
|
302
333
|
---
|
|
303
334
|
|
|
304
|
-
##
|
|
335
|
+
## 8. 2.0 candidate — one lifecycle for every operation
|
|
305
336
|
|
|
306
337
|
Create an internal typed lifecycle and adapt every family to it:
|
|
307
338
|
|
|
@@ -324,7 +355,7 @@ Also in the 2.0 window, because each requires a breaking change:
|
|
|
324
355
|
|
|
325
356
|
---
|
|
326
357
|
|
|
327
|
-
##
|
|
358
|
+
## 9. Longer-term backlog
|
|
328
359
|
|
|
329
360
|
1. MCP client and server adapters with asset and tool interoperability.
|
|
330
361
|
2. Video generation through the same asynchronous operation, job, and asset contracts.
|
|
@@ -349,7 +380,7 @@ Also in the 2.0 window, because each requires a breaking change:
|
|
|
349
380
|
|
|
350
381
|
---
|
|
351
382
|
|
|
352
|
-
##
|
|
383
|
+
## 10. Design notes carried forward
|
|
353
384
|
|
|
354
385
|
These decisions predate this revision and still hold.
|
|
355
386
|
|
|
@@ -401,7 +432,7 @@ await ai.images.edit({
|
|
|
401
432
|
|
|
402
433
|
---
|
|
403
434
|
|
|
404
|
-
##
|
|
435
|
+
## 11. How an item graduates
|
|
405
436
|
|
|
406
437
|
1. Provider-neutral types and a deterministic mock land first.
|
|
407
438
|
2. One real adapter proves the contract; conformance fixtures cover it.
|
package/dist/images/manager.js
CHANGED
|
@@ -1,117 +1,5 @@
|
|
|
1
1
|
import { ImageCapabilityError, ImageError, ImageOperationCancelledError, ImageProviderError, ImageProviderNotFoundError, ImageProviderResponseError, ImageSafetyError, ImageValidationError, } from './errors.js';
|
|
2
|
-
|
|
3
|
-
class LocalOperationHandle {
|
|
4
|
-
id;
|
|
5
|
-
now;
|
|
6
|
-
controller = new AbortController();
|
|
7
|
-
history = [];
|
|
8
|
-
waiters = new Set();
|
|
9
|
-
resultPromise;
|
|
10
|
-
resolveResult;
|
|
11
|
-
rejectResult;
|
|
12
|
-
currentStatus = 'queued';
|
|
13
|
-
sequence = 0;
|
|
14
|
-
constructor(id, now) {
|
|
15
|
-
this.id = id;
|
|
16
|
-
this.now = now;
|
|
17
|
-
this.resultPromise = new Promise((resolve, reject) => {
|
|
18
|
-
this.resolveResult = resolve;
|
|
19
|
-
this.rejectResult = reject;
|
|
20
|
-
});
|
|
21
|
-
// Consumers may choose events() without calling result(); keep that valid without hiding rejection from result().
|
|
22
|
-
void this.resultPromise.catch(() => undefined);
|
|
23
|
-
this.emit({ ...this.eventBase(), type: 'queued', status: 'queued' });
|
|
24
|
-
}
|
|
25
|
-
status() {
|
|
26
|
-
return this.currentStatus;
|
|
27
|
-
}
|
|
28
|
-
result() {
|
|
29
|
-
return this.resultPromise;
|
|
30
|
-
}
|
|
31
|
-
cancel(reason) {
|
|
32
|
-
if (TERMINAL_STATUSES.has(this.currentStatus) || this.currentStatus === 'cancelling')
|
|
33
|
-
return false;
|
|
34
|
-
this.currentStatus = 'cancelling';
|
|
35
|
-
this.emit({ ...this.eventBase(), type: 'cancelling', status: 'cancelling', reason });
|
|
36
|
-
this.controller.abort(reason);
|
|
37
|
-
this.currentStatus = 'cancelled';
|
|
38
|
-
this.emit({ ...this.eventBase(), type: 'cancelled', status: 'cancelled', reason });
|
|
39
|
-
this.rejectResult(new ImageOperationCancelledError(this.id, reason));
|
|
40
|
-
return true;
|
|
41
|
-
}
|
|
42
|
-
events() {
|
|
43
|
-
return this.iterateEvents();
|
|
44
|
-
}
|
|
45
|
-
start(executor) {
|
|
46
|
-
queueMicrotask(() => {
|
|
47
|
-
void this.run(executor);
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
async run(executor) {
|
|
51
|
-
if (TERMINAL_STATUSES.has(this.currentStatus))
|
|
52
|
-
return;
|
|
53
|
-
this.currentStatus = 'running';
|
|
54
|
-
this.emit({ ...this.eventBase(), type: 'running', status: 'running' });
|
|
55
|
-
try {
|
|
56
|
-
const value = await executor(this.controller.signal);
|
|
57
|
-
if (TERMINAL_STATUSES.has(this.currentStatus))
|
|
58
|
-
return;
|
|
59
|
-
this.currentStatus = 'succeeded';
|
|
60
|
-
this.emit({ ...this.eventBase(), type: 'succeeded', status: 'succeeded', result: value });
|
|
61
|
-
this.resolveResult(value);
|
|
62
|
-
}
|
|
63
|
-
catch (error) {
|
|
64
|
-
if (TERMINAL_STATUSES.has(this.currentStatus))
|
|
65
|
-
return;
|
|
66
|
-
this.currentStatus = 'failed';
|
|
67
|
-
this.emit({ ...this.eventBase(), type: 'failed', status: 'failed', error: describeError(error) });
|
|
68
|
-
this.rejectResult(error);
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
async *iterateEvents() {
|
|
72
|
-
let index = 0;
|
|
73
|
-
let waiter;
|
|
74
|
-
try {
|
|
75
|
-
while (true) {
|
|
76
|
-
const event = this.history[index];
|
|
77
|
-
if (event) {
|
|
78
|
-
index += 1;
|
|
79
|
-
yield event;
|
|
80
|
-
if (TERMINAL_STATUSES.has(event.status))
|
|
81
|
-
return;
|
|
82
|
-
continue;
|
|
83
|
-
}
|
|
84
|
-
if (TERMINAL_STATUSES.has(this.currentStatus))
|
|
85
|
-
return;
|
|
86
|
-
await new Promise((resolve) => {
|
|
87
|
-
waiter = resolve;
|
|
88
|
-
this.waiters.add(resolve);
|
|
89
|
-
});
|
|
90
|
-
if (waiter)
|
|
91
|
-
this.waiters.delete(waiter);
|
|
92
|
-
waiter = undefined;
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
finally {
|
|
96
|
-
if (waiter)
|
|
97
|
-
this.waiters.delete(waiter);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
eventBase() {
|
|
101
|
-
return {
|
|
102
|
-
operationId: this.id,
|
|
103
|
-
sequence: ++this.sequence,
|
|
104
|
-
timestamp: this.now().toISOString(),
|
|
105
|
-
};
|
|
106
|
-
}
|
|
107
|
-
emit(event) {
|
|
108
|
-
this.history.push(event);
|
|
109
|
-
const waiters = [...this.waiters];
|
|
110
|
-
this.waiters.clear();
|
|
111
|
-
for (const wake of waiters)
|
|
112
|
-
wake();
|
|
113
|
-
}
|
|
114
|
-
}
|
|
2
|
+
import { LocalOperationHandle } from '../operations/handle.js';
|
|
115
3
|
export class ImageManager {
|
|
116
4
|
config;
|
|
117
5
|
providers = new Map();
|
|
@@ -148,7 +36,12 @@ export class ImageManager {
|
|
|
148
36
|
submit(input, request) {
|
|
149
37
|
const submission = normalizeSubmission(input, request);
|
|
150
38
|
const operationId = this.createOperationId();
|
|
151
|
-
|
|
39
|
+
// The shared handle is told to reject with the image family's own cancellation error, so this
|
|
40
|
+
// refactor is invisible to existing callers catching ImageOperationCancelledError.
|
|
41
|
+
const handle = new LocalOperationHandle(operationId, {
|
|
42
|
+
now: this.now,
|
|
43
|
+
cancellationError: (id, reason) => new ImageOperationCancelledError(id, reason),
|
|
44
|
+
});
|
|
152
45
|
const externalSignal = submission.request.signal;
|
|
153
46
|
const onAbort = () => {
|
|
154
47
|
handle.cancel(abortReason(externalSignal));
|
|
@@ -550,13 +443,6 @@ function knownImageMimeType(format) {
|
|
|
550
443
|
function normalizeMimeType(value) {
|
|
551
444
|
return value.split(';', 1)[0]?.trim().toLowerCase() ?? '';
|
|
552
445
|
}
|
|
553
|
-
function describeError(error) {
|
|
554
|
-
if (error instanceof ImageError)
|
|
555
|
-
return { name: error.name, message: error.message, code: error.code };
|
|
556
|
-
if (error instanceof Error)
|
|
557
|
-
return { name: error.name, message: error.message };
|
|
558
|
-
return { name: 'Error', message: String(error) };
|
|
559
|
-
}
|
|
560
446
|
function assertSafetyFindings(message, findings) {
|
|
561
447
|
const blocked = findings.filter((finding) => finding.action === 'block');
|
|
562
448
|
if (blocked.length > 0)
|
package/dist/index.d.ts
CHANGED
|
@@ -12,6 +12,14 @@ export { SemanticCache, type SemanticCacheOptions } from './cache/semantic-cache
|
|
|
12
12
|
export { ResponseFormatError } from './core/response-format.js';
|
|
13
13
|
export { ContextWindowManager, type ContextWindowRuntime, } from './context/index.js';
|
|
14
14
|
export { VoiceManager, VoiceSession, VoiceProviderError, VoiceCapabilityError, type VoiceCompletionClient, type VoiceSessionCompletionClient, type VoiceSessionRuntime, } from './voice/index.js';
|
|
15
|
+
export { OperationRunner } from './operations/runner.js';
|
|
16
|
+
export { LocalOperationHandle, describeOperationError } from './operations/handle.js';
|
|
17
|
+
export { MemoryOperationStore, assertSerializableRecord } from './operations/store.js';
|
|
18
|
+
export { BullMQOperationDispatcher, RedisOperationStore, type BullMQLikeOperationQueue, type RedisOperationLikeClient, } from './operations/adapters.js';
|
|
19
|
+
export { OPERATION_WEBHOOK_SIGNATURE_HEADER, deliverOperationWebhook, signOperationWebhook, verifyOperationWebhook, } from './operations/webhooks.js';
|
|
20
|
+
export { TERMINAL_OPERATION_STATUSES } from './types/operations.js';
|
|
21
|
+
export { allowedTransitions, assertTransition, canTransition, isClaimable, isSettled, isTerminalOperationStatus, } from './operations/state-machine.js';
|
|
22
|
+
export { OperationCancelledError, OperationConflictError, OperationError, OperationExpiredError, OperationLeaseLostError, OperationNotFoundError, OperationSerializationError, OperationTransitionError, } from './operations/errors.js';
|
|
15
23
|
export { ImageManager } from './images/manager.js';
|
|
16
24
|
export { ImageCapabilityError, ImageError, ImageOperationCancelledError, ImageProviderError, ImageProviderNotFoundError, ImageProviderResponseError, ImageSafetyError, ImageValidationError, } from './images/errors.js';
|
|
17
25
|
export { TelephonyManager, TelephonyProviderError, TelephonyCapabilityError, createVoiceTwiML, } from './telephony/index.js';
|
|
@@ -79,6 +87,7 @@ export type { ContextSummaryConfig, ContextSummaryInput, ContextSummaryMode, Con
|
|
|
79
87
|
export type { SpeechRequest, SpeechResponse, TranscriptionRequest, TranscriptionResponse, TranscriptionSegment, TranscriptionWord, VoiceAudioFormat, VoiceAudioInput, VoiceAudioOutput, VoiceConfig, VoicePromptText, VoiceProvider, VoiceProviderInfo, VoiceSessionConfig, VoiceSessionToolStep, VoiceSessionTurnInput, VoiceSessionTurnResponse, VoiceTaskPrompt, VoiceTaskPromptMatcher, VoiceTaskPromptMatcherInput, VoiceTranscriptMessageConfig, VoiceTurnRequest, VoiceTurnResponse, } from './types/voice.js';
|
|
80
88
|
export type { AssetBytesLocation, AssetChecksum, AssetDescriptor, AssetInput, AssetLocation, AssetLocationKind, AssetProvenance, AssetStoredLocation, AssetUrlLocation, ImageBackground, ImageConfig, ImageDelivery, ImageDimensions, ImageEditRequest, ImageGenerateRequest, ImageManagerConfig, ImageMaskInput, ImageOperation, ImageOperationSubmission, ImageOutputFormat, ImageProvider, ImageProviderCallContext, ImageProviderCapabilities, ImageProviderInfo, ImageQuality, ImageRequestBase, ImageResult, ImageSafetyContext, ImageSafetyPolicy, ImageWarning, MediaSafetyFinding, MediaUsage, OperationErrorDescriptor, OperationEvent, OperationEventBase, OperationHandle, OperationMeta, OperationStatus, } from './types/images.js';
|
|
81
89
|
export type { AssetCapacityConstraint, AssetPutOptions, AssetSignOptions, AssetSigner, AssetSignerContext, AssetStat, AssetStore, AssetStoreResult, ByteAssetDescriptor, ByteAssetInput, MemoryAssetStoreOptions, MemoryAssetStoreSnapshot, } from './images/assets.js';
|
|
90
|
+
export type { DurableOperationHandle, OperationContext, OperationDispatcher, OperationExecutor, OperationLease, OperationRecord, OperationRetryConfig, OperationRunnerConfig, OperationStore, OperationSubmitOptions, OperationWebhookConfig, } from './types/operations.js';
|
|
82
91
|
export type { Embedding, EmbeddingConfig, EmbeddingCostBudgetConfig, EmbeddingEncodingFormat, EmbeddingInput, EmbeddingInputType, EmbeddingMeta, EmbeddingModelCapabilities, EmbeddingModelRegistryConfig, EmbeddingProviderCallContext, EmbeddingProviderCapabilities, EmbeddingProviderInfo, EmbeddingProviderRequest, EmbeddingProviderResult, EmbeddingProviderUsage, EmbeddingRequest, EmbeddingResponse, EmbeddingTruncateMode, EmbeddingsProvider, } from './types/embeddings.js';
|
|
83
92
|
export type { CreateCallRequest, CreateCallResponse, TelephonyAudioEncoding, TelephonyCallDirection, TelephonyCallStatus, TelephonyConfig, TelephonyConnectedEvent, TelephonyDtmfEvent, TelephonyGatherConfig, TelephonyHttpMethod, TelephonyMarkEvent, TelephonyMediaEvent, TelephonyMediaStreamEvent, TelephonyOutboundAudioMessage, TelephonyProvider, TelephonyProviderInfo, TelephonyResponseRequest, TelephonyStartEvent, TelephonyStopEvent, TelephonyStreamConfig, TelephonyStreamMode, TelephonyStreamTrack, TelephonyWebhookResponse, TelephonyWebhookValidationRequest, } from './types/telephony.js';
|
|
84
93
|
export type { AliasMetadata, AliasStage, CacheTtl, Modality, ModelCapabilities, ModelEndpoint, ModelStatus, PromptCachingCapability, ProviderCapabilities, ReasoningEffort, RoutingModelPreference, } from './types/providers.js';
|
package/dist/index.js
CHANGED
|
@@ -12,6 +12,14 @@ export { SemanticCache } from './cache/semantic-cache.js';
|
|
|
12
12
|
export { ResponseFormatError } from './core/response-format.js';
|
|
13
13
|
export { ContextWindowManager, } from './context/index.js';
|
|
14
14
|
export { VoiceManager, VoiceSession, VoiceProviderError, VoiceCapabilityError, } from './voice/index.js';
|
|
15
|
+
export { OperationRunner } from './operations/runner.js';
|
|
16
|
+
export { LocalOperationHandle, describeOperationError } from './operations/handle.js';
|
|
17
|
+
export { MemoryOperationStore, assertSerializableRecord } from './operations/store.js';
|
|
18
|
+
export { BullMQOperationDispatcher, RedisOperationStore, } from './operations/adapters.js';
|
|
19
|
+
export { OPERATION_WEBHOOK_SIGNATURE_HEADER, deliverOperationWebhook, signOperationWebhook, verifyOperationWebhook, } from './operations/webhooks.js';
|
|
20
|
+
export { TERMINAL_OPERATION_STATUSES } from './types/operations.js';
|
|
21
|
+
export { allowedTransitions, assertTransition, canTransition, isClaimable, isSettled, isTerminalOperationStatus, } from './operations/state-machine.js';
|
|
22
|
+
export { OperationCancelledError, OperationConflictError, OperationError, OperationExpiredError, OperationLeaseLostError, OperationNotFoundError, OperationSerializationError, OperationTransitionError, } from './operations/errors.js';
|
|
15
23
|
export { ImageManager } from './images/manager.js';
|
|
16
24
|
export { ImageCapabilityError, ImageError, ImageOperationCancelledError, ImageProviderError, ImageProviderNotFoundError, ImageProviderResponseError, ImageSafetyError, ImageValidationError, } from './images/errors.js';
|
|
17
25
|
export { TelephonyManager, TelephonyProviderError, TelephonyCapabilityError, createVoiceTwiML, } from './telephony/index.js';
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { OperationDispatcher, OperationRecord, OperationStore } from '../types/operations.js';
|
|
2
|
+
/**
|
|
3
|
+
* The Redis commands the operation store needs.
|
|
4
|
+
*
|
|
5
|
+
* Structural rather than tied to one client, so `ioredis`, `node-redis`, or a cluster proxy all
|
|
6
|
+
* satisfy it without this package depending on any of them.
|
|
7
|
+
*/
|
|
8
|
+
export interface RedisOperationLikeClient {
|
|
9
|
+
hget(key: string, field: string): Promise<string | null> | string | null;
|
|
10
|
+
hset(key: string, field: string, value: string): Promise<unknown> | unknown;
|
|
11
|
+
hdel(key: string, field: string): Promise<unknown> | unknown;
|
|
12
|
+
hvals(key: string): Promise<string[]> | string[];
|
|
13
|
+
/** Optional CAS primitive. When absent the store falls back to a read-compare-write. */
|
|
14
|
+
eval?(script: string, numKeys: number, ...args: string[]): Promise<unknown> | unknown;
|
|
15
|
+
}
|
|
16
|
+
export interface RedisOperationStoreOptions {
|
|
17
|
+
prefix?: string;
|
|
18
|
+
/** Disables the Lua compare-and-set even when the client exposes `eval`. */
|
|
19
|
+
useEval?: boolean;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Redis-backed operation storage, so a submitted operation survives a restart.
|
|
23
|
+
*
|
|
24
|
+
* Updates are a compare-and-set on `sequence`. When the client exposes `eval` the comparison and
|
|
25
|
+
* the write happen in one Lua call, which is genuinely atomic; otherwise the store falls back to a
|
|
26
|
+
* read-compare-write that narrows the race but cannot close it. Prefer a client with `eval`
|
|
27
|
+
* whenever more than one worker can touch the same operation.
|
|
28
|
+
*/
|
|
29
|
+
export declare class RedisOperationStore<TResult = unknown> implements OperationStore<TResult> {
|
|
30
|
+
private readonly client;
|
|
31
|
+
private readonly prefix;
|
|
32
|
+
private readonly useEval;
|
|
33
|
+
constructor(client: RedisOperationLikeClient, options?: RedisOperationStoreOptions);
|
|
34
|
+
create(record: OperationRecord<TResult>): Promise<void>;
|
|
35
|
+
read(id: string): Promise<OperationRecord<TResult> | undefined>;
|
|
36
|
+
update(record: OperationRecord<TResult>, expectedSequence: number): Promise<boolean>;
|
|
37
|
+
delete(id: string): Promise<boolean>;
|
|
38
|
+
claimExpired(now: string, limit: number): Promise<Array<OperationRecord<TResult>>>;
|
|
39
|
+
findByIdempotencyKey(key: string): Promise<OperationRecord<TResult> | undefined>;
|
|
40
|
+
list(): Promise<Array<OperationRecord<TResult>>>;
|
|
41
|
+
private recordsKey;
|
|
42
|
+
private idempotencyKey;
|
|
43
|
+
}
|
|
44
|
+
export interface BullMQLikeOperationQueue {
|
|
45
|
+
add(name: string, data: unknown, options?: Record<string, unknown>): Promise<{
|
|
46
|
+
id?: string | number;
|
|
47
|
+
}> | {
|
|
48
|
+
id?: string | number;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export interface BullMQOperationDispatcherOptions {
|
|
52
|
+
/** Job name used for every dispatched operation. Defaults to `nexus-operation`. */
|
|
53
|
+
jobName?: string;
|
|
54
|
+
/** Merged into the BullMQ job options, for priority, delay, or removal policy. */
|
|
55
|
+
jobOptions?: Record<string, unknown>;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Hands accepted operations to a BullMQ queue for a worker process to execute.
|
|
59
|
+
*
|
|
60
|
+
* Only the operation id and its routing metadata are queued — never the record's result or any
|
|
61
|
+
* payload — so the job stays small and the store remains the single source of truth. The worker
|
|
62
|
+
* reads the record by id, which is also what makes a redelivered job safe.
|
|
63
|
+
*/
|
|
64
|
+
export declare class BullMQOperationDispatcher implements OperationDispatcher {
|
|
65
|
+
private readonly queue;
|
|
66
|
+
private readonly options;
|
|
67
|
+
constructor(queue: BullMQLikeOperationQueue, options?: BullMQOperationDispatcherOptions);
|
|
68
|
+
dispatch(record: OperationRecord<unknown>): Promise<void>;
|
|
69
|
+
}
|