@zhivex-ai/core 1.5.0 → 1.7.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.
Files changed (119) hide show
  1. package/README.md +39 -0
  2. package/dist/agent-handoff-contracts.d.ts +12 -0
  3. package/dist/agent-handoff-contracts.d.ts.map +1 -0
  4. package/dist/agent-handoff-contracts.js +18 -0
  5. package/dist/agent-handoff-contracts.js.map +1 -0
  6. package/dist/agent-handoff.d.ts +2 -11
  7. package/dist/agent-handoff.d.ts.map +1 -1
  8. package/dist/agent-handoff.js +1 -18
  9. package/dist/agent-handoff.js.map +1 -1
  10. package/dist/agent-trace.d.ts +1 -5
  11. package/dist/agent-trace.d.ts.map +1 -1
  12. package/dist/agent-trace.js.map +1 -1
  13. package/dist/agent.d.ts +1 -0
  14. package/dist/agent.d.ts.map +1 -1
  15. package/dist/agent.js +358 -215
  16. package/dist/agent.js.map +1 -1
  17. package/dist/api-stability.d.ts.map +1 -1
  18. package/dist/api-stability.js +36 -25
  19. package/dist/api-stability.js.map +1 -1
  20. package/dist/artifact.d.ts +32 -9
  21. package/dist/artifact.d.ts.map +1 -1
  22. package/dist/artifact.js +428 -82
  23. package/dist/artifact.js.map +1 -1
  24. package/dist/catalog.d.ts +42 -3
  25. package/dist/catalog.d.ts.map +1 -1
  26. package/dist/catalog.js +269 -7
  27. package/dist/catalog.js.map +1 -1
  28. package/dist/contracts.d.ts +8 -0
  29. package/dist/contracts.d.ts.map +1 -0
  30. package/dist/contracts.js +2 -0
  31. package/dist/contracts.js.map +1 -0
  32. package/dist/generate-text.d.ts.map +1 -1
  33. package/dist/generate-text.js +6 -3
  34. package/dist/generate-text.js.map +1 -1
  35. package/dist/google.d.ts +1 -60
  36. package/dist/google.d.ts.map +1 -1
  37. package/dist/google.js +0 -198
  38. package/dist/google.js.map +1 -1
  39. package/dist/index.d.ts +13 -7
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +7 -4
  42. package/dist/index.js.map +1 -1
  43. package/dist/live-agent.js +1 -1
  44. package/dist/live-agent.js.map +1 -1
  45. package/dist/middleware.d.ts +16 -0
  46. package/dist/middleware.d.ts.map +1 -1
  47. package/dist/middleware.js +476 -110
  48. package/dist/middleware.js.map +1 -1
  49. package/dist/model-capabilities.d.ts +66 -0
  50. package/dist/model-capabilities.d.ts.map +1 -0
  51. package/dist/model-capabilities.js +136 -0
  52. package/dist/model-capabilities.js.map +1 -0
  53. package/dist/node.d.ts +8 -0
  54. package/dist/node.d.ts.map +1 -0
  55. package/dist/node.js +8 -0
  56. package/dist/node.js.map +1 -0
  57. package/dist/observability.d.ts +57 -10
  58. package/dist/observability.d.ts.map +1 -1
  59. package/dist/observability.js +535 -114
  60. package/dist/observability.js.map +1 -1
  61. package/dist/pricing.d.ts +7 -0
  62. package/dist/pricing.d.ts.map +1 -0
  63. package/dist/pricing.js +2 -0
  64. package/dist/pricing.js.map +1 -0
  65. package/dist/provider-resources.d.ts +61 -0
  66. package/dist/provider-resources.d.ts.map +1 -0
  67. package/dist/provider-resources.js +199 -0
  68. package/dist/provider-resources.js.map +1 -0
  69. package/dist/runner.js +1 -1
  70. package/dist/runner.js.map +1 -1
  71. package/dist/runtime-entry.d.ts +15 -0
  72. package/dist/runtime-entry.d.ts.map +1 -0
  73. package/dist/runtime-entry.js +14 -0
  74. package/dist/runtime-entry.js.map +1 -0
  75. package/dist/runtime.d.ts +9 -1
  76. package/dist/runtime.d.ts.map +1 -1
  77. package/dist/runtime.js +35 -8
  78. package/dist/runtime.js.map +1 -1
  79. package/dist/secure-id-node.d.ts +2 -0
  80. package/dist/secure-id-node.d.ts.map +1 -0
  81. package/dist/secure-id-node.js +3 -0
  82. package/dist/secure-id-node.js.map +1 -0
  83. package/dist/secure-id.d.ts.map +1 -1
  84. package/dist/secure-id.js +6 -2
  85. package/dist/secure-id.js.map +1 -1
  86. package/dist/testing.d.ts +4 -0
  87. package/dist/testing.d.ts.map +1 -0
  88. package/dist/testing.js +4 -0
  89. package/dist/testing.js.map +1 -0
  90. package/dist/types.d.ts +74 -6
  91. package/dist/types.d.ts.map +1 -1
  92. package/dist/ui-entry.d.ts +6 -0
  93. package/dist/ui-entry.d.ts.map +1 -0
  94. package/dist/ui-entry.js +5 -0
  95. package/dist/ui-entry.js.map +1 -0
  96. package/dist/ui.js +1 -1
  97. package/dist/ui.js.map +1 -1
  98. package/dist/workflow-observability.d.ts +13 -0
  99. package/dist/workflow-observability.d.ts.map +1 -0
  100. package/dist/workflow-observability.js +186 -0
  101. package/dist/workflow-observability.js.map +1 -0
  102. package/dist/workflow-state-contracts.d.ts +76 -0
  103. package/dist/workflow-state-contracts.d.ts.map +1 -0
  104. package/dist/workflow-state-contracts.js +49 -0
  105. package/dist/workflow-state-contracts.js.map +1 -0
  106. package/dist/workflow-state-service.d.ts +3 -34
  107. package/dist/workflow-state-service.d.ts.map +1 -1
  108. package/dist/workflow-state-service.js +2 -2
  109. package/dist/workflow-state-service.js.map +1 -1
  110. package/dist/workflow.d.ts +53 -42
  111. package/dist/workflow.d.ts.map +1 -1
  112. package/dist/workflow.js +258 -125
  113. package/dist/workflow.js.map +1 -1
  114. package/dist/workflows-entry.d.ts +9 -0
  115. package/dist/workflows-entry.d.ts.map +1 -0
  116. package/dist/workflows-entry.js +5 -0
  117. package/dist/workflows-entry.js.map +1 -0
  118. package/package.json +42 -1
  119. package/secure-id-internal.d.ts +1 -0
@@ -1,7 +1,91 @@
1
- import { promises as fs } from "node:fs";
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { constants, promises as fs } from "node:fs";
2
3
  import path from "node:path";
3
- const serializeInput = (input) => JSON.stringify(input);
4
+ import { ValidationError } from "./errors.js";
5
+ import { canonicalStoreFileStem, ensurePrivateDirectory, writePrivateFile } from "./store-security.js";
6
+ const cacheKeySensitiveField = /(?:^|[-_])(api[-_]?key|authorization|cookie|credential|password|secret|token)(?:$|[-_])/iu;
7
+ const cacheKeyOmittedFields = new Set([
8
+ "abortSignal",
9
+ "execute",
10
+ "inputGuardrails",
11
+ "isEnabled",
12
+ "onError",
13
+ "outputGuardrails",
14
+ "signal"
15
+ ]);
16
+ const canonicalCacheInput = (value, seen = new WeakSet()) => {
17
+ if (value === null)
18
+ return "null";
19
+ if (value === undefined)
20
+ return '"[undefined]"';
21
+ if (typeof value === "string" || typeof value === "boolean")
22
+ return JSON.stringify(value);
23
+ if (typeof value === "number") {
24
+ if (Number.isNaN(value))
25
+ return '"[number:NaN]"';
26
+ if (value === Number.POSITIVE_INFINITY)
27
+ return '"[number:Infinity]"';
28
+ if (value === Number.NEGATIVE_INFINITY)
29
+ return '"[number:-Infinity]"';
30
+ return JSON.stringify(Object.is(value, -0) ? 0 : value);
31
+ }
32
+ if (typeof value === "bigint")
33
+ return JSON.stringify(`[bigint:${value.toString()}]`);
34
+ if (typeof value === "function" || typeof value === "symbol") {
35
+ throw new TypeError("Generate cache keys cannot canonicalize functions or symbols.");
36
+ }
37
+ if (value instanceof Date)
38
+ return JSON.stringify(`[date:${value.toISOString()}]`);
39
+ if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
40
+ const bytes = value instanceof ArrayBuffer
41
+ ? new Uint8Array(value)
42
+ : new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
43
+ return JSON.stringify(`[bytes:sha256:${createHash("sha256").update(bytes).digest("hex")}]`);
44
+ }
45
+ if (typeof AbortSignal !== "undefined" && value instanceof AbortSignal) {
46
+ return '"[abort-signal]"';
47
+ }
48
+ if (seen.has(value)) {
49
+ throw new TypeError("Generate cache keys cannot canonicalize cyclic input.");
50
+ }
51
+ seen.add(value);
52
+ try {
53
+ if (Array.isArray(value)) {
54
+ return `[${value.map((entry) => canonicalCacheInput(entry, seen)).join(",")}]`;
55
+ }
56
+ return `{${Object.keys(value)
57
+ .filter((key) => !cacheKeyOmittedFields.has(key))
58
+ .sort()
59
+ .map((key) => {
60
+ if (cacheKeySensitiveField.test(key)) {
61
+ throw new TypeError("Generate cache keys cannot include sensitive fields.");
62
+ }
63
+ const entry = value[key];
64
+ const serialized = canonicalCacheInput(entry, seen);
65
+ return `${JSON.stringify(key)}:${serialized}`;
66
+ })
67
+ .join(",")}}`;
68
+ }
69
+ finally {
70
+ seen.delete(value);
71
+ }
72
+ };
73
+ const createDefaultGenerateCacheKey = (input) => `generate:v2:${createHash("sha256").update(canonicalCacheInput(input)).digest("hex")}`;
74
+ const defaultGenerateCacheModelScopes = new WeakMap();
75
+ const getDefaultGenerateCacheModelScope = (model) => {
76
+ const existing = defaultGenerateCacheModelScopes.get(model);
77
+ if (existing) {
78
+ return existing;
79
+ }
80
+ const created = `model-instance:${randomUUID()}`;
81
+ defaultGenerateCacheModelScopes.set(model, created);
82
+ return created;
83
+ };
4
84
  const telemetryObserversSymbol = Symbol("zhivex-ai.telemetry-observers");
85
+ const DEFAULT_FILE_GENERATE_CACHE_MAX_KEY_BYTES = 1024 * 1024;
86
+ const DEFAULT_FILE_GENERATE_CACHE_MAX_ENTRY_BYTES = 16 * 1024 * 1024;
87
+ const FILE_GENERATE_CACHE_READ_CHUNK_BYTES = 64 * 1024;
88
+ const FILE_GENERATE_CACHE_SCHEMA_VERSION = 1;
5
89
  const getTelemetryObservers = (model) => [...(model[telemetryObserversSymbol] ?? [])];
6
90
  export const emitLanguageModelTelemetryEvent = async (model, event) => {
7
91
  const observers = getTelemetryObservers(model);
@@ -75,13 +159,26 @@ export const wrapLanguageModel = (model, middlewares) => {
75
159
  return wrappedModel;
76
160
  };
77
161
  export const createTelemetryMiddleware = (options) => {
162
+ let nextGenerateId = 1;
163
+ let nextStreamId = 1;
164
+ const reportTelemetry = async (event) => {
165
+ try {
166
+ await options.onEvent(event);
167
+ }
168
+ catch {
169
+ // Telemetry is best-effort and never owns the model operation outcome.
170
+ }
171
+ };
78
172
  const middleware = {
79
173
  name: "telemetry",
80
- onTelemetryEvent: options.onEvent,
174
+ onTelemetryEvent: reportTelemetry,
81
175
  async wrapGenerate(context, next) {
176
+ const generateId = nextGenerateId;
177
+ nextGenerateId += 1;
82
178
  const startedAt = Date.now();
83
- await options.onEvent({
179
+ await reportTelemetry({
84
180
  type: "generate-start",
181
+ generateId,
85
182
  model: context.model,
86
183
  input: context.input,
87
184
  startedAt
@@ -89,8 +186,9 @@ export const createTelemetryMiddleware = (options) => {
89
186
  try {
90
187
  const output = await next();
91
188
  const finishedAt = Date.now();
92
- await options.onEvent({
189
+ await reportTelemetry({
93
190
  type: "generate-finish",
191
+ generateId,
94
192
  model: context.model,
95
193
  input: context.input,
96
194
  output,
@@ -103,8 +201,9 @@ export const createTelemetryMiddleware = (options) => {
103
201
  catch (error) {
104
202
  const finishedAt = Date.now();
105
203
  const err = error instanceof Error ? error : new Error(String(error));
106
- await options.onEvent({
204
+ await reportTelemetry({
107
205
  type: "generate-error",
206
+ generateId,
108
207
  model: context.model,
109
208
  input: context.input,
110
209
  error: err,
@@ -116,93 +215,189 @@ export const createTelemetryMiddleware = (options) => {
116
215
  }
117
216
  },
118
217
  async wrapStream(context, next) {
119
- const startedAt = Date.now();
120
- await options.onEvent({
121
- type: "stream-start",
122
- model: context.model,
123
- input: context.input,
124
- startedAt
125
- });
126
- try {
127
- const stream = await next();
128
- return (async function* () {
129
- let finishReason;
130
- let providerFinishReason;
131
- let usage;
132
- try {
133
- for await (const event of stream) {
134
- if (event.type === "finish") {
135
- finishReason = event.finishReason;
136
- providerFinishReason = event.providerFinishReason;
137
- usage = event.usage;
138
- }
139
- yield event;
218
+ const streamId = nextStreamId;
219
+ nextStreamId += 1;
220
+ return (async function* () {
221
+ const operationStartedAt = Date.now();
222
+ let finishReason;
223
+ let providerFinishReason;
224
+ let usage;
225
+ let firstChunkAt;
226
+ let previousChunkAt;
227
+ let outputChunkCount = 0;
228
+ let terminalEventEmitted = false;
229
+ try {
230
+ await reportTelemetry({
231
+ type: "stream-start",
232
+ streamId,
233
+ model: context.model,
234
+ input: context.input,
235
+ startedAt: operationStartedAt
236
+ });
237
+ const stream = await next();
238
+ for await (const event of stream) {
239
+ if (event.type === "finish") {
240
+ finishReason = event.finishReason;
241
+ providerFinishReason = event.providerFinishReason;
242
+ usage = event.usage;
243
+ const finishedAt = Date.now();
244
+ terminalEventEmitted = true;
245
+ await reportTelemetry({
246
+ type: "stream-finish",
247
+ streamId,
248
+ model: context.model,
249
+ input: context.input,
250
+ startedAt: operationStartedAt,
251
+ finishedAt,
252
+ latencyMs: finishedAt - operationStartedAt,
253
+ finishReason,
254
+ providerFinishReason,
255
+ usage,
256
+ outputChunkCount
257
+ });
258
+ }
259
+ else if (event.type === "error") {
260
+ const finishedAt = Date.now();
261
+ terminalEventEmitted = true;
262
+ await reportTelemetry({
263
+ type: "stream-error",
264
+ streamId,
265
+ model: context.model,
266
+ input: context.input,
267
+ error: event.error,
268
+ startedAt: operationStartedAt,
269
+ finishedAt,
270
+ latencyMs: finishedAt - operationStartedAt,
271
+ outputChunkCount
272
+ });
273
+ }
274
+ else {
275
+ const chunkAt = Date.now();
276
+ firstChunkAt ??= chunkAt;
277
+ const timeSincePreviousChunkMs = previousChunkAt === undefined
278
+ ? undefined
279
+ : chunkAt - previousChunkAt;
280
+ previousChunkAt = chunkAt;
281
+ outputChunkCount += 1;
282
+ await reportTelemetry({
283
+ type: "stream-chunk",
284
+ streamId,
285
+ model: context.model,
286
+ input: context.input,
287
+ startedAt: operationStartedAt,
288
+ chunkAt,
289
+ chunkIndex: outputChunkCount,
290
+ timeToFirstChunkMs: outputChunkCount === 1 ? chunkAt - operationStartedAt : undefined,
291
+ timeSincePreviousChunkMs
292
+ });
140
293
  }
294
+ yield event;
295
+ if (terminalEventEmitted)
296
+ return;
297
+ }
298
+ if (!terminalEventEmitted) {
141
299
  const finishedAt = Date.now();
142
- await options.onEvent({
300
+ terminalEventEmitted = true;
301
+ await reportTelemetry({
143
302
  type: "stream-finish",
303
+ streamId,
144
304
  model: context.model,
145
305
  input: context.input,
146
- startedAt,
306
+ startedAt: operationStartedAt,
147
307
  finishedAt,
148
- latencyMs: finishedAt - startedAt,
308
+ latencyMs: finishedAt - operationStartedAt,
149
309
  finishReason,
150
310
  providerFinishReason,
151
- usage
311
+ usage,
312
+ outputChunkCount
152
313
  });
153
314
  }
154
- catch (error) {
155
- const finishedAt = Date.now();
156
- const err = error instanceof Error ? error : new Error(String(error));
157
- await options.onEvent({
315
+ }
316
+ catch (error) {
317
+ const finishedAt = Date.now();
318
+ const err = error instanceof Error ? error : new Error(String(error));
319
+ if (!terminalEventEmitted) {
320
+ terminalEventEmitted = true;
321
+ await reportTelemetry({
158
322
  type: "stream-error",
323
+ streamId,
159
324
  model: context.model,
160
325
  input: context.input,
161
326
  error: err,
162
- startedAt,
327
+ startedAt: operationStartedAt,
163
328
  finishedAt,
164
- latencyMs: finishedAt - startedAt
329
+ latencyMs: finishedAt - operationStartedAt,
330
+ outputChunkCount
165
331
  });
166
- throw error;
167
332
  }
168
- })();
169
- }
170
- catch (error) {
171
- const finishedAt = Date.now();
172
- const err = error instanceof Error ? error : new Error(String(error));
173
- await options.onEvent({
174
- type: "stream-error",
175
- model: context.model,
176
- input: context.input,
177
- error: err,
178
- startedAt,
179
- finishedAt,
180
- latencyMs: finishedAt - startedAt
181
- });
182
- throw error;
183
- }
333
+ throw error;
334
+ }
335
+ finally {
336
+ if (!terminalEventEmitted) {
337
+ terminalEventEmitted = true;
338
+ const finishedAt = Date.now();
339
+ const error = Object.assign(new Error("Stream consumption ended before the provider stream completed."), { name: "AbortError" });
340
+ await reportTelemetry({
341
+ type: "stream-error",
342
+ streamId,
343
+ model: context.model,
344
+ input: context.input,
345
+ error,
346
+ startedAt: operationStartedAt,
347
+ finishedAt,
348
+ latencyMs: finishedAt - operationStartedAt,
349
+ outputChunkCount
350
+ });
351
+ }
352
+ }
353
+ })();
184
354
  }
185
355
  };
186
356
  return middleware;
187
357
  };
188
- export const createCachedGenerateMiddleware = (options) => ({
189
- name: "cache",
190
- async wrapGenerate(context, next) {
191
- const key = options.getKey?.(context.input, context.model) ??
192
- serializeInput({
193
- provider: context.model.provider,
194
- modelId: context.model.modelId,
195
- input: context.input
196
- });
197
- const cached = await options.cache.get(key);
198
- if (cached) {
199
- return cached;
200
- }
201
- const output = await next();
202
- await options.cache.set(key, output);
203
- return output;
358
+ export const createCachedGenerateMiddleware = (options) => {
359
+ if (typeof options.scope === "string" && options.scope.trim().length === 0) {
360
+ throw new ValidationError('The generate cache "scope" must be a non-empty string.');
204
361
  }
205
- });
362
+ return {
363
+ name: "cache",
364
+ async wrapGenerate(context, next) {
365
+ if (!options.getKey && options.scope === undefined && options.cache.scopeRequirement === "stable") {
366
+ return next();
367
+ }
368
+ let key;
369
+ if (options.getKey) {
370
+ key = options.getKey(context.input, context.model);
371
+ }
372
+ else {
373
+ const scope = typeof options.scope === "function"
374
+ ? options.scope(context.input, context.model)
375
+ : options.scope ?? getDefaultGenerateCacheModelScope(context.model);
376
+ if (typeof scope !== "string" || scope.trim().length === 0) {
377
+ throw new ValidationError('The generate cache "scope" must resolve to a non-empty string.');
378
+ }
379
+ try {
380
+ key = createDefaultGenerateCacheKey({
381
+ scope,
382
+ provider: context.model.provider,
383
+ modelId: context.model.modelId,
384
+ input: context.input
385
+ });
386
+ }
387
+ catch {
388
+ return next();
389
+ }
390
+ }
391
+ const cached = await options.cache.get(key);
392
+ if (cached) {
393
+ return cached;
394
+ }
395
+ const output = await next();
396
+ await options.cache.set(key, output);
397
+ return output;
398
+ }
399
+ };
400
+ };
206
401
  export const createInMemoryGenerateCache = () => {
207
402
  const store = new Map();
208
403
  return {
@@ -214,13 +409,85 @@ export const createInMemoryGenerateCache = () => {
214
409
  }
215
410
  };
216
411
  };
412
+ const positiveFileCacheLimit = (value, fallback, name) => {
413
+ const resolved = value ?? fallback;
414
+ if (!Number.isSafeInteger(resolved) || resolved <= 0) {
415
+ throw new ValidationError(`The file generate cache "${name}" limit must be a positive safe integer.`);
416
+ }
417
+ return resolved;
418
+ };
419
+ const readFileWithinLimit = async (filePath, maxBytes) => {
420
+ const handle = await fs.open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW);
421
+ try {
422
+ const stat = await handle.stat();
423
+ if (!stat.isFile() || stat.size > maxBytes) {
424
+ return undefined;
425
+ }
426
+ const chunks = [];
427
+ let receivedBytes = 0;
428
+ while (receivedBytes <= maxBytes) {
429
+ const chunk = Buffer.allocUnsafe(Math.min(FILE_GENERATE_CACHE_READ_CHUNK_BYTES, maxBytes - receivedBytes + 1));
430
+ const { bytesRead } = await handle.read(chunk, 0, chunk.byteLength, null);
431
+ if (bytesRead === 0) {
432
+ return Buffer.concat(chunks, receivedBytes).toString("utf8");
433
+ }
434
+ receivedBytes += bytesRead;
435
+ if (receivedBytes > maxBytes) {
436
+ return undefined;
437
+ }
438
+ chunks.push(chunk.subarray(0, bytesRead));
439
+ }
440
+ return undefined;
441
+ }
442
+ finally {
443
+ await handle.close();
444
+ }
445
+ };
217
446
  export const createFileGenerateCache = (options) => {
218
- const getPath = (key) => path.join(options.dir, `${Buffer.from(key).toString("base64url")}.json`);
447
+ const maxKeyBytes = positiveFileCacheLimit(options.maxKeyBytes, DEFAULT_FILE_GENERATE_CACHE_MAX_KEY_BYTES, "maxKeyBytes");
448
+ const maxEntryBytes = positiveFileCacheLimit(options.maxEntryBytes, DEFAULT_FILE_GENERATE_CACHE_MAX_ENTRY_BYTES, "maxEntryBytes");
449
+ if (options.ttlMs !== undefined && (!Number.isSafeInteger(options.ttlMs) || options.ttlMs <= 0)) {
450
+ throw new ValidationError('The file generate cache "ttlMs" must be a positive safe integer.');
451
+ }
452
+ const keyIsWithinLimit = (key) => Buffer.byteLength(key, "utf8") <= maxKeyBytes;
453
+ const getPath = (key) => path.join(options.dir, `${canonicalStoreFileStem("generate-cache", [key])}.json`);
219
454
  return {
455
+ scopeRequirement: "stable",
220
456
  async get(key) {
457
+ if (!keyIsWithinLimit(key)) {
458
+ return undefined;
459
+ }
221
460
  try {
222
- const file = await fs.readFile(getPath(key), "utf8");
223
- return JSON.parse(file);
461
+ const file = await readFileWithinLimit(getPath(key), maxEntryBytes);
462
+ if (file === undefined) {
463
+ return undefined;
464
+ }
465
+ try {
466
+ const parsed = JSON.parse(file);
467
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
468
+ return undefined;
469
+ }
470
+ const envelope = parsed;
471
+ if (envelope.schemaVersion !== FILE_GENERATE_CACHE_SCHEMA_VERSION ||
472
+ typeof envelope.createdAt !== "number" ||
473
+ !Number.isSafeInteger(envelope.createdAt) ||
474
+ envelope.value === null ||
475
+ typeof envelope.value !== "object" ||
476
+ Array.isArray(envelope.value)) {
477
+ return undefined;
478
+ }
479
+ if (options.ttlMs !== undefined && Date.now() - envelope.createdAt >= options.ttlMs) {
480
+ await fs.unlink(getPath(key)).catch(() => undefined);
481
+ return undefined;
482
+ }
483
+ return envelope.value;
484
+ }
485
+ catch (error) {
486
+ if (error instanceof SyntaxError) {
487
+ return undefined;
488
+ }
489
+ throw error;
490
+ }
224
491
  }
225
492
  catch (error) {
226
493
  const err = error;
@@ -231,57 +498,156 @@ export const createFileGenerateCache = (options) => {
231
498
  }
232
499
  },
233
500
  async set(key, value) {
234
- await fs.mkdir(options.dir, { recursive: true });
235
- await fs.writeFile(getPath(key), JSON.stringify(value), "utf8");
501
+ if (!keyIsWithinLimit(key)) {
502
+ return;
503
+ }
504
+ let serialized;
505
+ try {
506
+ serialized = JSON.stringify({
507
+ schemaVersion: FILE_GENERATE_CACHE_SCHEMA_VERSION,
508
+ createdAt: Date.now(),
509
+ value
510
+ });
511
+ }
512
+ catch (error) {
513
+ if (error instanceof TypeError) {
514
+ return;
515
+ }
516
+ throw error;
517
+ }
518
+ if (Buffer.byteLength(serialized, "utf8") > maxEntryBytes) {
519
+ return;
520
+ }
521
+ await ensurePrivateDirectory(options.dir);
522
+ await fs.chmod(options.dir, 0o700);
523
+ await writePrivateFile(getPath(key), serialized);
236
524
  }
237
525
  };
238
526
  };
239
527
  export const createCircuitBreakerMiddleware = (options) => {
240
- const failureThreshold = Math.max(1, options.failureThreshold ?? 3);
241
- const cooldownMs = Math.max(0, options.cooldownMs ?? 30_000);
242
- const state = { failures: 0 };
528
+ const failureThreshold = options.failureThreshold ?? 3;
529
+ const cooldownMs = options.cooldownMs ?? 30_000;
530
+ if (!Number.isSafeInteger(failureThreshold) || failureThreshold <= 0) {
531
+ throw new ValidationError('The circuit breaker "failureThreshold" must be a positive safe integer.');
532
+ }
533
+ if (!Number.isSafeInteger(cooldownMs) || cooldownMs < 0) {
534
+ throw new ValidationError('The circuit breaker "cooldownMs" must be a non-negative safe integer.');
535
+ }
536
+ const states = new WeakMap();
537
+ const getState = (model) => {
538
+ const existing = states.get(model);
539
+ if (existing) {
540
+ return existing;
541
+ }
542
+ const created = { failures: 0, halfOpenProbeInFlight: false };
543
+ states.set(model, created);
544
+ return created;
545
+ };
546
+ const notifyStateChange = async (model, state, status) => {
547
+ try {
548
+ await options.onStateChange?.({
549
+ failures: state.failures,
550
+ openedAt: state.openedAt,
551
+ model,
552
+ status
553
+ });
554
+ }
555
+ catch {
556
+ // State observers are operational hooks and must not alter model outcomes.
557
+ }
558
+ };
559
+ const openError = (model) => new Error(`Circuit breaker open for model "${model.provider}/${model.modelId}".`);
560
+ const beginRequest = async (model) => {
561
+ const state = getState(model);
562
+ if (state.openedAt === undefined) {
563
+ return { state, halfOpenProbe: false };
564
+ }
565
+ const cooldownElapsed = Date.now() - state.openedAt >= cooldownMs;
566
+ if (!cooldownElapsed || state.halfOpenProbeInFlight) {
567
+ throw openError(model);
568
+ }
569
+ state.halfOpenProbeInFlight = true;
570
+ await notifyStateChange(model, state, "half-open");
571
+ return { state, halfOpenProbe: true };
572
+ };
573
+ const markSuccess = async (model, permit) => {
574
+ const { state } = permit;
575
+ if (!permit.halfOpenProbe && state.openedAt !== undefined) {
576
+ return;
577
+ }
578
+ const changed = state.failures > 0 || state.openedAt !== undefined || state.halfOpenProbeInFlight;
579
+ state.failures = 0;
580
+ state.openedAt = undefined;
581
+ state.halfOpenProbeInFlight = false;
582
+ if (changed) {
583
+ await notifyStateChange(model, state, "closed");
584
+ }
585
+ };
586
+ const markFailure = async (model, permit, error) => {
587
+ const { state } = permit;
588
+ const err = error instanceof Error ? error : new Error(String(error));
589
+ let isFailure = true;
590
+ try {
591
+ isFailure = options.isFailure?.(err) ?? true;
592
+ }
593
+ catch {
594
+ isFailure = true;
595
+ }
596
+ if (!isFailure) {
597
+ if (permit.halfOpenProbe) {
598
+ await markSuccess(model, permit);
599
+ }
600
+ return;
601
+ }
602
+ const wasOpen = state.openedAt !== undefined;
603
+ state.failures += 1;
604
+ if (permit.halfOpenProbe) {
605
+ state.halfOpenProbeInFlight = false;
606
+ state.openedAt = Date.now();
607
+ await notifyStateChange(model, state, "open");
608
+ }
609
+ else if (!wasOpen && state.failures >= failureThreshold) {
610
+ state.openedAt = Date.now();
611
+ await notifyStateChange(model, state, "open");
612
+ }
613
+ };
243
614
  return {
244
615
  name: "circuit-breaker",
245
616
  async wrapGenerate(context, next) {
246
- const now = Date.now();
247
- if (state.openedAt && now - state.openedAt < cooldownMs) {
248
- throw new Error(`Circuit breaker open for model "${context.model.provider}/${context.model.modelId}".`);
249
- }
250
- if (state.openedAt && now - state.openedAt >= cooldownMs) {
251
- await options.onStateChange?.({
252
- ...state,
253
- model: context.model,
254
- status: "half-open"
255
- });
256
- }
617
+ const permit = await beginRequest(context.model);
257
618
  try {
258
619
  const result = await next();
259
- state.failures = 0;
260
- state.openedAt = undefined;
261
- await options.onStateChange?.({
262
- ...state,
263
- model: context.model,
264
- status: "closed"
265
- });
620
+ await markSuccess(context.model, permit);
266
621
  return result;
267
622
  }
268
623
  catch (error) {
269
- const err = error instanceof Error ? error : new Error(String(error));
270
- const isFailure = options.isFailure?.(err) ?? true;
271
- if (!isFailure) {
624
+ await markFailure(context.model, permit, error);
625
+ throw error;
626
+ }
627
+ },
628
+ async wrapStream(context, next) {
629
+ return (async function* () {
630
+ const permit = await beginRequest(context.model);
631
+ let settled = false;
632
+ try {
633
+ const stream = await next();
634
+ for await (const event of stream) {
635
+ yield event;
636
+ }
637
+ await markSuccess(context.model, permit);
638
+ settled = true;
639
+ }
640
+ catch (error) {
641
+ await markFailure(context.model, permit, error);
642
+ settled = true;
272
643
  throw error;
273
644
  }
274
- state.failures += 1;
275
- if (state.failures >= failureThreshold) {
276
- state.openedAt = Date.now();
277
- await options.onStateChange?.({
278
- ...state,
279
- model: context.model,
280
- status: "open"
281
- });
645
+ finally {
646
+ if (!settled && permit.halfOpenProbe) {
647
+ permit.state.halfOpenProbeInFlight = false;
648
+ }
282
649
  }
283
- throw error;
284
- }
650
+ })();
285
651
  }
286
652
  };
287
653
  };