vern-llm 1.6.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.
- package/dist/index.cjs +247 -233
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +165 -168
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +165 -168
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +246 -234
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -209,6 +209,15 @@ function extractRetryAfterMs(err, maxDelayMs = DEFAULT_MAX_DELAY_MS) {
|
|
|
209
209
|
if (!Number.isNaN(dateMs)) return Math.max(0, Math.min(dateMs - Date.now(), maxDelayMs));
|
|
210
210
|
return void 0;
|
|
211
211
|
}
|
|
212
|
+
/** Converts any thrown value into a well-typed LLMError. */
|
|
213
|
+
function normalizeError(error, signal) {
|
|
214
|
+
if (signal?.aborted) return new LLMError("LLM request aborted", "aborted");
|
|
215
|
+
if (error instanceof LLMError) return error;
|
|
216
|
+
const status = extractStatus(error);
|
|
217
|
+
const retryAfterMs = extractRetryAfterMs(error);
|
|
218
|
+
if (status !== void 0) return new LLMError("LLM request failed", "api", status, void 0, error, retryAfterMs);
|
|
219
|
+
return new LLMError("LLM request failed", "unknown", void 0, void 0, error, retryAfterMs);
|
|
220
|
+
}
|
|
212
221
|
/**
|
|
213
222
|
* Exponential backoff with jitter, capped at maxDelayMs.
|
|
214
223
|
* Jitter avoids thundering-herd retries when many callers back off in lockstep,
|
|
@@ -238,6 +247,54 @@ async function waitForRetry(delay, signal) {
|
|
|
238
247
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
239
248
|
});
|
|
240
249
|
}
|
|
250
|
+
/**
|
|
251
|
+
* Runs `getResult` after reserving usage, if a `reserveUsage` hook was
|
|
252
|
+
* provided. `refundUsage` fires only if a reservation was actually made.
|
|
253
|
+
* `onRefundError` is called (instead of throwing) whenever a refund attempt
|
|
254
|
+
* itself fails, so a broken refund hook never masks the original error.
|
|
255
|
+
*/
|
|
256
|
+
async function withReservedUsage(params, coalesced, getResult, signal, onRefundError) {
|
|
257
|
+
if (signal?.aborted) throw new LLMError("LLM request aborted", "aborted");
|
|
258
|
+
let reserved = false;
|
|
259
|
+
try {
|
|
260
|
+
if (params.reserveUsage) {
|
|
261
|
+
await params.reserveUsage({
|
|
262
|
+
coalesced,
|
|
263
|
+
signal
|
|
264
|
+
});
|
|
265
|
+
reserved = true;
|
|
266
|
+
}
|
|
267
|
+
} catch (error) {
|
|
268
|
+
if (signal?.aborted) throw new LLMError("LLM request aborted", "aborted");
|
|
269
|
+
throw new LLMError(error instanceof Error ? error.message : "Usage reservation failed", "quota_exceeded", void 0, void 0, error);
|
|
270
|
+
}
|
|
271
|
+
const refund = async (logMessage) => {
|
|
272
|
+
try {
|
|
273
|
+
await params.refundUsage?.({
|
|
274
|
+
coalesced,
|
|
275
|
+
signal
|
|
276
|
+
});
|
|
277
|
+
} catch (refundError) {
|
|
278
|
+
onRefundError(logMessage, refundError);
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
if (signal?.aborted) {
|
|
282
|
+
if (reserved) await refund("[VernLLM] refundUsage failed after abort");
|
|
283
|
+
throw new LLMError("LLM request aborted", "aborted");
|
|
284
|
+
}
|
|
285
|
+
let result;
|
|
286
|
+
try {
|
|
287
|
+
result = await getResult();
|
|
288
|
+
} catch (error) {
|
|
289
|
+
if (reserved) await refund("[VernLLM] refundUsage failed");
|
|
290
|
+
throw error;
|
|
291
|
+
}
|
|
292
|
+
if (signal?.aborted) {
|
|
293
|
+
if (reserved) await refund("[VernLLM] refundUsage failed after abort");
|
|
294
|
+
throw new LLMError("LLM request aborted", "aborted");
|
|
295
|
+
}
|
|
296
|
+
return result;
|
|
297
|
+
}
|
|
241
298
|
|
|
242
299
|
//#endregion
|
|
243
300
|
//#region src/logger.ts
|
|
@@ -312,16 +369,73 @@ var InMemoryCacheAdapter = class {
|
|
|
312
369
|
}
|
|
313
370
|
}
|
|
314
371
|
};
|
|
372
|
+
/**
|
|
373
|
+
* Normalizes keys before caching to avoid duplicate entries from formatting differences.
|
|
374
|
+
*/
|
|
375
|
+
var NormalizedCacheAdapter = class {
|
|
376
|
+
constructor(inner = new InMemoryCacheAdapter()) {
|
|
377
|
+
this.inner = inner;
|
|
378
|
+
}
|
|
379
|
+
normalize(key) {
|
|
380
|
+
return key.toLowerCase().trim().replace(/[^\p{L}\p{N}\s]/gu, " ").replace(/\s+/g, " ").trim();
|
|
381
|
+
}
|
|
382
|
+
async resolveKey(key) {
|
|
383
|
+
return this.normalize(key);
|
|
384
|
+
}
|
|
385
|
+
async get(key) {
|
|
386
|
+
return this.inner.get(this.normalize(key));
|
|
387
|
+
}
|
|
388
|
+
async set(key, value, ttl) {
|
|
389
|
+
await this.inner.set(this.normalize(key), value, ttl);
|
|
390
|
+
}
|
|
391
|
+
async delete(key) {
|
|
392
|
+
await this.inner.delete?.(this.normalize(key));
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
/**
|
|
396
|
+
* Two-tier cache with fast local L1 and shared L2.
|
|
397
|
+
* L2 hits are promoted back to L1.
|
|
398
|
+
*/
|
|
399
|
+
var TieredCacheAdapter = class {
|
|
400
|
+
constructor(l1, l2, l1Ttl) {
|
|
401
|
+
this.l1 = l1;
|
|
402
|
+
this.l2 = l2;
|
|
403
|
+
this.l1Ttl = l1Ttl;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Forwards to L1's `resolveKey` if it has one, otherwise L2's. L1 is
|
|
407
|
+
* preferred since `get()` checks L1 first, so its notion of "the same
|
|
408
|
+
* key" is the one that determines whether a lookup can skip L2 entirely.
|
|
409
|
+
*/
|
|
410
|
+
async resolveKey(key) {
|
|
411
|
+
if (this.l1.resolveKey) return this.l1.resolveKey(key);
|
|
412
|
+
if (this.l2.resolveKey) return this.l2.resolveKey(key);
|
|
413
|
+
return key;
|
|
414
|
+
}
|
|
415
|
+
async get(key) {
|
|
416
|
+
const l1Result = await this.l1.get(key);
|
|
417
|
+
if (l1Result.hit) return l1Result;
|
|
418
|
+
const l2Result = await this.l2.get(key);
|
|
419
|
+
if (l2Result.hit) await this.l1.set(key, l2Result.value, this.l1Ttl ?? 60);
|
|
420
|
+
return l2Result;
|
|
421
|
+
}
|
|
422
|
+
async set(key, value, ttl) {
|
|
423
|
+
await Promise.all([this.l1.set(key, value, this.l1Ttl ?? ttl), this.l2.set(key, value, ttl)]);
|
|
424
|
+
}
|
|
425
|
+
async delete(key) {
|
|
426
|
+
await Promise.all([this.l1.delete?.(key), this.l2.delete?.(key)]);
|
|
427
|
+
}
|
|
428
|
+
};
|
|
315
429
|
|
|
316
430
|
//#endregion
|
|
317
431
|
//#region src/vernLLM.ts
|
|
318
432
|
/**
|
|
319
|
-
* A resilient
|
|
433
|
+
* A resilient layer around an LLM chat completions client, this is VernLLM!
|
|
320
434
|
*
|
|
321
|
-
* Adds retry with
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
435
|
+
* Adds retry with backoff/jitter, per-attempt timeouts, an optional circuit breaker,
|
|
436
|
+
* JSON parsing with optional schema validation, usage tracking, and an
|
|
437
|
+
* optional response cache, all configurable, all opt-in beyond sensible
|
|
438
|
+
* defaults.
|
|
325
439
|
*/
|
|
326
440
|
var VernLLM = class {
|
|
327
441
|
client;
|
|
@@ -338,18 +452,18 @@ var VernLLM = class {
|
|
|
338
452
|
logger;
|
|
339
453
|
breaker;
|
|
340
454
|
/**
|
|
341
|
-
* @param options
|
|
342
|
-
*
|
|
343
|
-
*
|
|
455
|
+
* @param options - Client, model, and tunables. Notable defaults:
|
|
456
|
+
* `maxRetries` 1, `timeoutMs` 25000, `baseDelayMs` 500 (exponential backoff
|
|
457
|
+
* base), `defaultMaxTokens` 1000, `cache` an in-memory adapter,
|
|
458
|
+
* `nonRetryableStatus` `[400, 401, 403, 404, 422]`, `debug` false.
|
|
344
459
|
*/
|
|
345
460
|
constructor(options) {
|
|
346
461
|
this.client = options.client;
|
|
347
462
|
this.model = options.model;
|
|
348
|
-
|
|
349
|
-
this.
|
|
350
|
-
this.
|
|
351
|
-
this.
|
|
352
|
-
this.defaultMaxTokens = retryConfig.defaultMaxTokens;
|
|
463
|
+
this.maxRetries = options.maxRetries ?? 1;
|
|
464
|
+
this.timeoutMs = options.timeoutMs ?? 25e3;
|
|
465
|
+
this.baseDelayMs = options.baseDelayMs ?? 500;
|
|
466
|
+
this.defaultMaxTokens = options.defaultMaxTokens ?? 1e3;
|
|
353
467
|
this.cache = options.cache ?? new InMemoryCacheAdapter();
|
|
354
468
|
this.nonRetryableStatus = options.nonRetryableStatus ?? [
|
|
355
469
|
400,
|
|
@@ -360,72 +474,40 @@ var VernLLM = class {
|
|
|
360
474
|
];
|
|
361
475
|
this.parseJson = options.parseJson ?? defaultParseJson;
|
|
362
476
|
this.onUsage = options.onUsage;
|
|
363
|
-
this.logger =
|
|
364
|
-
this.breaker =
|
|
365
|
-
}
|
|
366
|
-
/**
|
|
367
|
-
* Resolves retry/timeout/token defaults from the given options,
|
|
368
|
-
* falling back to the librarys built-in defaults for anything unset
|
|
369
|
-
*/
|
|
370
|
-
resolveRetryConfig(options) {
|
|
371
|
-
return {
|
|
372
|
-
maxRetries: options.maxRetries ?? 1,
|
|
373
|
-
timeoutMs: options.timeoutMs ?? 25e3,
|
|
374
|
-
baseDelayMs: options.baseDelayMs ?? 500,
|
|
375
|
-
defaultMaxTokens: options.defaultMaxTokens ?? 1e3
|
|
376
|
-
};
|
|
477
|
+
this.logger = options.logger ?? new ConsoleLogger(options.debug ?? false);
|
|
478
|
+
this.breaker = options.circuitBreaker ? new CircuitBreaker(options.circuitBreaker === true ? void 0 : options.circuitBreaker) : void 0;
|
|
377
479
|
}
|
|
378
|
-
/**
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
* so response content isn't unintentionally written to logs)
|
|
382
|
-
*/
|
|
383
|
-
resolveLogger(options) {
|
|
384
|
-
return options.logger ?? new ConsoleLogger(options.debug ?? false);
|
|
480
|
+
/** Resolves a cache key through the adapter when it supports normalization. */
|
|
481
|
+
async resolveCacheKey(key) {
|
|
482
|
+
return this.cache.resolveKey ? await this.cache.resolveKey(key) : key;
|
|
385
483
|
}
|
|
386
484
|
/**
|
|
387
|
-
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
|
|
391
|
-
resolveCircuitBreaker(options) {
|
|
392
|
-
if (!options.circuitBreaker) return void 0;
|
|
393
|
-
return new CircuitBreaker(options.circuitBreaker === true ? void 0 : options.circuitBreaker);
|
|
394
|
-
}
|
|
395
|
-
/**
|
|
396
|
-
* Makes a single logical LLM call, transparently retrying on failure
|
|
397
|
-
* according to the configured retry policy
|
|
398
|
-
*
|
|
399
|
-
* Fails fast if the circuit breaker is open or the signal is already
|
|
400
|
-
* aborted, before any request is dispatched. On exhausting all
|
|
401
|
-
* retries, records a circuit breaker failure and rejects with a
|
|
402
|
-
* normalized LLMError
|
|
485
|
+
* Makes a single logical LLM call, retrying on failure per the configured
|
|
486
|
+
* policy. Fails fast if the breaker is open or the signal is already
|
|
487
|
+
* aborted. On exhausting retries, records a breaker failure and rejects
|
|
488
|
+
* with a normalized LLMError.
|
|
403
489
|
*
|
|
404
|
-
* @param params
|
|
405
|
-
*
|
|
406
|
-
* @returns The parsed and optionally schema-validated response, or
|
|
407
|
-
*
|
|
490
|
+
* @param params - System/user content plus per-call overrides (model,
|
|
491
|
+
* temperature, jsonMode, schema, signal, etc). See `CallParams`.
|
|
492
|
+
* @returns The parsed (and optionally schema-validated) response, or the
|
|
493
|
+
* raw string content when `jsonMode` is false and no `jsonSchema` is set.
|
|
408
494
|
*/
|
|
409
495
|
async call(params) {
|
|
410
496
|
this.breaker?.assertClosed();
|
|
411
497
|
if (params.signal?.aborted) throw new LLMError("LLM request aborted", "aborted");
|
|
412
498
|
const requestId = params.requestId ?? (0, crypto.randomUUID)();
|
|
413
|
-
return
|
|
499
|
+
return withReservedUsage(params, false, async () => {
|
|
414
500
|
try {
|
|
415
501
|
return await this.retryWithBackoff(() => this.executeCall(params, requestId), requestId, params.signal);
|
|
416
502
|
} catch (error) {
|
|
417
|
-
const normalized =
|
|
503
|
+
const normalized = normalizeError(error, params.signal);
|
|
418
504
|
if (normalized.type !== "validation" && normalized.type !== "parse" && normalized.type !== "aborted") this.breaker?.recordFailure();
|
|
419
505
|
this.logger.debug(`[vern:${requestId}] error:\n${describeError(error)}`);
|
|
420
506
|
throw normalized;
|
|
421
507
|
}
|
|
422
|
-
}, params.signal);
|
|
508
|
+
}, params.signal, (logMessage, error) => this.logRefundError(logMessage, error));
|
|
423
509
|
}
|
|
424
|
-
/**
|
|
425
|
-
* Runs `fn`, retrying with backoff according to `shouldRetry` policy
|
|
426
|
-
* Purely mechanical: knows nothing about LLM specifics beyond the retry
|
|
427
|
-
* predicate, so its testable independent of request/response shaping
|
|
428
|
-
*/
|
|
510
|
+
/** Runs `fn`, retrying with backoff according to `shouldRetry`. */
|
|
429
511
|
async retryWithBackoff(fn, requestId, signal) {
|
|
430
512
|
let lastError;
|
|
431
513
|
for (let attempt = 0; attempt <= this.maxRetries; attempt++) try {
|
|
@@ -438,24 +520,9 @@ var VernLLM = class {
|
|
|
438
520
|
throw lastError;
|
|
439
521
|
}
|
|
440
522
|
/**
|
|
441
|
-
* Converts any thrown value into a well-typed LLMError for the public
|
|
442
|
-
* API surface. Preserves an existing LLMError as is, reports aborted
|
|
443
|
-
* signals as such, classifies errors carrying an http status as
|
|
444
|
-
* type api, and otherwise falls back to a generic unknown error.
|
|
445
|
-
*/
|
|
446
|
-
normalizeError(error, signal) {
|
|
447
|
-
if (signal?.aborted) return new LLMError("LLM request aborted", "aborted");
|
|
448
|
-
if (error instanceof LLMError) return error;
|
|
449
|
-
const status = extractStatus(error);
|
|
450
|
-
const retryAfterMs = extractRetryAfterMs(error);
|
|
451
|
-
if (status !== void 0) return new LLMError("LLM request failed", "api", status, void 0, error, retryAfterMs);
|
|
452
|
-
return new LLMError("LLM request failed", "unknown", void 0, void 0, error, retryAfterMs);
|
|
453
|
-
}
|
|
454
|
-
/**
|
|
455
523
|
* Performs a single attempt: builds the request, dispatches it with a
|
|
456
|
-
* timeout, and shapes the response. Throws on an empty response so
|
|
457
|
-
*
|
|
458
|
-
* usage and a circuit breaker success before returning
|
|
524
|
+
* timeout, and shapes the response. Throws on an empty response so the
|
|
525
|
+
* retry loop treats it like any other transient failure.
|
|
459
526
|
*/
|
|
460
527
|
async executeCall(params, requestId) {
|
|
461
528
|
const { useJson, model, request } = this.buildRequestPayload(params);
|
|
@@ -473,12 +540,8 @@ var VernLLM = class {
|
|
|
473
540
|
return result;
|
|
474
541
|
}
|
|
475
542
|
/**
|
|
476
|
-
*
|
|
477
|
-
*
|
|
478
|
-
* this validates `history` up front rather than letting a malformed
|
|
479
|
-
* request surface as a confusing provider-side error. Thrown as a
|
|
480
|
-
* validation LLMError, which `shouldRetry` never retries, since retrying
|
|
481
|
-
* the same malformed input can't succeed
|
|
543
|
+
* Validates `history` alternates user/assistant turns, since providers
|
|
544
|
+
* like Anthropic/Gemini reject or mishandle consecutive same-role turns.
|
|
482
545
|
*/
|
|
483
546
|
validateHistory(history) {
|
|
484
547
|
let previousRole;
|
|
@@ -489,12 +552,7 @@ var VernLLM = class {
|
|
|
489
552
|
}
|
|
490
553
|
if (previousRole === "user") throw new LLMError("The last entry in history is a \"user\" turn, which would collide with the current userContent turn. history must end with an \"assistant\" turn (or be empty).", "validation");
|
|
491
554
|
}
|
|
492
|
-
/**
|
|
493
|
-
* Applies per call defaults and shapes the params into the request
|
|
494
|
-
* object expected by the underlying client, including the resolved
|
|
495
|
-
* response format. Also returns whether JSON parsing should be
|
|
496
|
-
* applied to the response and which model was ultimately used
|
|
497
|
-
*/
|
|
555
|
+
/** Applies per-call defaults and shapes params into the client's request object. */
|
|
498
556
|
buildRequestPayload(params) {
|
|
499
557
|
const { systemPrompt, userContent, history = [], temperature = .2, jsonMode = true, maxTokens = this.defaultMaxTokens, model = this.model, reasoningEffort, jsonSchema } = params;
|
|
500
558
|
const useJson = jsonMode || Boolean(jsonSchema);
|
|
@@ -529,11 +587,10 @@ var VernLLM = class {
|
|
|
529
587
|
};
|
|
530
588
|
}
|
|
531
589
|
/**
|
|
532
|
-
* Chooses the response format
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
*
|
|
536
|
-
* for plain text responses
|
|
590
|
+
* Chooses the response format: a provider-native `jsonSchema` takes
|
|
591
|
+
* priority when supplied (constrains generation directly), otherwise
|
|
592
|
+
* falls back to the looser `json_object` mode when JSON output is
|
|
593
|
+
* requested, or no format at all for plain text responses.
|
|
537
594
|
*/
|
|
538
595
|
buildResponseFormat(jsonSchema, useJson) {
|
|
539
596
|
if (jsonSchema) return {
|
|
@@ -547,15 +604,7 @@ var VernLLM = class {
|
|
|
547
604
|
};
|
|
548
605
|
return useJson ? { type: "json_object" } : void 0;
|
|
549
606
|
}
|
|
550
|
-
/**
|
|
551
|
-
* Reports token usage to the caller supplied onUsage callback, when
|
|
552
|
-
* both a callback was configured and the provider actually returned
|
|
553
|
-
* usage data on this response. A no op otherwise.
|
|
554
|
-
*
|
|
555
|
-
* A throwing onUsage callback is logged and swallowed rather than
|
|
556
|
-
* propagated, so a broken billing/metrics hook can't fail or retrigger
|
|
557
|
-
* retries on an otherwise-successful call.
|
|
558
|
-
*/
|
|
607
|
+
/** Reports token usage to `onUsage`, swallowing and logging any error it throws. */
|
|
559
608
|
recordUsage(response, requestId, model) {
|
|
560
609
|
if (!response.usage || !this.onUsage) return;
|
|
561
610
|
try {
|
|
@@ -570,12 +619,7 @@ var VernLLM = class {
|
|
|
570
619
|
this.logger.error("[VernLLM] onUsage failed", { message: error instanceof Error ? error.message : "unknown" });
|
|
571
620
|
}
|
|
572
621
|
}
|
|
573
|
-
/**
|
|
574
|
-
* Parses the raw response content as JSON and, when a schema is
|
|
575
|
-
* supplied, validates the parsed value against it. Throws a parse
|
|
576
|
-
* type LLMError on malformed JSON and a validation type LLMError,
|
|
577
|
-
* carrying the schemas issues, on a failed validation
|
|
578
|
-
*/
|
|
622
|
+
/** Parses response content as JSON and validates it against `schema` when supplied. */
|
|
579
623
|
parseAndValidate(content, schema) {
|
|
580
624
|
let parsed;
|
|
581
625
|
try {
|
|
@@ -590,12 +634,10 @@ var VernLLM = class {
|
|
|
590
634
|
return result.data;
|
|
591
635
|
}
|
|
592
636
|
/**
|
|
593
|
-
* Waits out the backoff delay for a
|
|
594
|
-
*
|
|
595
|
-
* Retry-After
|
|
596
|
-
*
|
|
597
|
-
* exponential backoff exactly as before. Rejects early if the signal
|
|
598
|
-
* aborts during the wait
|
|
637
|
+
* Waits out the backoff delay for a retry attempt, honoring a
|
|
638
|
+
* Retry-After header on the failed attempt's error when present.
|
|
639
|
+
* Both Retry-After and plain exponential backoff are capped at the same
|
|
640
|
+
* max delay (see `DEFAULT_MAX_DELAY_MS` in `vernLLM.utils.ts`).
|
|
599
641
|
*/
|
|
600
642
|
async recoverDelay(requestId, attempt, error, signal) {
|
|
601
643
|
const retryAfterMs = extractRetryAfterMs(error);
|
|
@@ -603,58 +645,56 @@ var VernLLM = class {
|
|
|
603
645
|
this.logger.warn(`[vern:${requestId}] recovery attempt ${attempt}/${this.maxRetries}, waiting ${delay}ms` + (retryAfterMs !== void 0 ? " (honoring Retry-After)" : ""));
|
|
604
646
|
await waitForRetry(delay, signal);
|
|
605
647
|
}
|
|
606
|
-
/**
|
|
607
|
-
* Decides whether a failed attempt is worth retrying. Never retries
|
|
608
|
-
* once the signal has aborted, never retries a parse or validation
|
|
609
|
-
* failure since those stem from the response content rather than a
|
|
610
|
-
* transient fault, and never retries a status code the caller has
|
|
611
|
-
* marked as non retryable. Retries everything else
|
|
612
|
-
*/
|
|
648
|
+
/** Decides whether a failed attempt is worth retrying. */
|
|
613
649
|
shouldRetry(error, signal) {
|
|
614
650
|
if (signal?.aborted) return false;
|
|
615
651
|
if (error instanceof LLMError && (error.type === "parse" || error.type === "validation")) return false;
|
|
616
652
|
const status = extractStatus(error);
|
|
617
|
-
|
|
618
|
-
return true;
|
|
653
|
+
return !(status !== void 0 && this.nonRetryableStatus.includes(status));
|
|
619
654
|
}
|
|
620
655
|
/**
|
|
621
656
|
* Removes a cached response by key when the configured cache adapter
|
|
622
|
-
* supports deletion.
|
|
623
|
-
*
|
|
624
|
-
* Cache invalidation remains the responsibility of the caller because
|
|
657
|
+
* supports deletion. Cache invalidation is the caller's responsibility;
|
|
625
658
|
* only the application knows when cached data is stale.
|
|
659
|
+
*
|
|
660
|
+
* @param key - The raw cache key (resolved through the adapter's
|
|
661
|
+
* `resolveKey`, if any, before deletion).
|
|
626
662
|
*/
|
|
627
663
|
async deleteCache(key) {
|
|
628
664
|
if (!this.cache.delete) return;
|
|
629
|
-
await this.cache.delete(key);
|
|
665
|
+
await this.cache.delete(await this.resolveCacheKey(key));
|
|
630
666
|
}
|
|
631
667
|
/**
|
|
632
|
-
* Cache wrapper around caller-supplied logic.
|
|
633
|
-
* `
|
|
634
|
-
* to the caller.
|
|
668
|
+
* Cache wrapper around caller-supplied logic. Concurrent misses for the
|
|
669
|
+
* same `cacheKey` share a single in-flight call, avoiding cache stampedes.
|
|
635
670
|
*
|
|
636
|
-
*
|
|
637
|
-
*
|
|
638
|
-
* `reserveUsage`/`refundUsage
|
|
671
|
+
* @param params - `cacheKey`, `ttl`, `fn` (the work to run on a cache
|
|
672
|
+
* miss, typically `() => this.call(...)`), and optional
|
|
673
|
+
* `reserveUsage`/`refundUsage`/`signal`. See `CachedCallParams`.
|
|
674
|
+
* @returns The cached value on a hit, or the result of `fn()` on a miss.
|
|
639
675
|
*/
|
|
640
676
|
async cachedCall(params) {
|
|
641
|
-
const
|
|
677
|
+
const resolvedKey = await this.resolveCacheKey(params.cacheKey);
|
|
678
|
+
const resolvedParams = resolvedKey === params.cacheKey ? params : {
|
|
679
|
+
...params,
|
|
680
|
+
cacheKey: resolvedKey
|
|
681
|
+
};
|
|
682
|
+
const cached = await this.cache.get(resolvedKey);
|
|
642
683
|
if (cached.hit) return cached.value;
|
|
643
|
-
const existing = this.inFlight.get(
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
return this.registerTrigger(params, coalesced);
|
|
684
|
+
const existing = this.inFlight.get(resolvedKey);
|
|
685
|
+
if (existing) return withReservedUsage(resolvedParams, true, () => existing, params.signal, (logMessage, error) => this.logRefundError(logMessage, error));
|
|
686
|
+
return this.registerTrigger(resolvedParams, false);
|
|
647
687
|
}
|
|
648
|
-
/** Starts the shared fn() call for a cache miss
|
|
688
|
+
/** Starts the shared fn() call for a cache miss and tracks it in the in-flight map until it settles. */
|
|
649
689
|
registerTrigger(params, coalesced) {
|
|
650
|
-
const resultPromise =
|
|
690
|
+
const resultPromise = withReservedUsage(params, coalesced, () => this.runAndCache(params), params.signal, (logMessage, error) => this.logRefundError(logMessage, error));
|
|
651
691
|
this.inFlight.set(params.cacheKey, resultPromise);
|
|
652
692
|
resultPromise.catch(() => {}).finally(() => {
|
|
653
693
|
this.inFlight.delete(params.cacheKey);
|
|
654
694
|
});
|
|
655
695
|
return resultPromise;
|
|
656
696
|
}
|
|
657
|
-
/** Runs `fn` and writes its result to the cache.
|
|
697
|
+
/** Runs `fn` and writes its result to the cache. */
|
|
658
698
|
async runAndCache(params) {
|
|
659
699
|
const result = await params.fn();
|
|
660
700
|
try {
|
|
@@ -664,76 +704,31 @@ var VernLLM = class {
|
|
|
664
704
|
}
|
|
665
705
|
return result;
|
|
666
706
|
}
|
|
667
|
-
/**
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
* i.e. `reserveUsage` was provided and it resolved successfully. If
|
|
671
|
-
* `reserveUsage` is omitted entirely, or if it throws, there is nothing to
|
|
672
|
-
* refund, so `refundUsage` is not invoked in either case.
|
|
673
|
-
*
|
|
674
|
-
* Shared by `cachedCall()`/`registerTrigger()` and `call()`, so it only
|
|
675
|
-
* depends on the usage hooks rather than LLM request concerns.
|
|
676
|
-
*/
|
|
677
|
-
async withReservedUsage(params, coalesced, getResult, signal) {
|
|
678
|
-
let reserved = false;
|
|
679
|
-
try {
|
|
680
|
-
if (params.reserveUsage) {
|
|
681
|
-
await params.reserveUsage({
|
|
682
|
-
coalesced,
|
|
683
|
-
signal
|
|
684
|
-
});
|
|
685
|
-
reserved = true;
|
|
686
|
-
}
|
|
687
|
-
} catch (error) {
|
|
688
|
-
throw new LLMError(error instanceof Error ? error.message : "Usage reservation failed", "quota_exceeded", void 0, void 0, error);
|
|
689
|
-
}
|
|
690
|
-
if (signal?.aborted) {
|
|
691
|
-
if (reserved) try {
|
|
692
|
-
await params.refundUsage?.({
|
|
693
|
-
coalesced,
|
|
694
|
-
signal
|
|
695
|
-
});
|
|
696
|
-
} catch (refundError) {
|
|
697
|
-
this.logger.error("[VernLLM] refundUsage failed after abort", { message: refundError instanceof Error ? refundError.message : "unknown" });
|
|
698
|
-
}
|
|
699
|
-
throw new LLMError("LLM request aborted", "aborted");
|
|
700
|
-
}
|
|
701
|
-
try {
|
|
702
|
-
return await getResult();
|
|
703
|
-
} catch (error) {
|
|
704
|
-
if (reserved) try {
|
|
705
|
-
await params.refundUsage?.({
|
|
706
|
-
coalesced,
|
|
707
|
-
signal
|
|
708
|
-
});
|
|
709
|
-
} catch (refundError) {
|
|
710
|
-
this.logger.error("[VernLLM] refundUsage failed", { message: refundError instanceof Error ? refundError.message : "unknown" });
|
|
711
|
-
}
|
|
712
|
-
throw error;
|
|
713
|
-
}
|
|
707
|
+
/** Logs a failed refundUsage attempt via the configured logger. */
|
|
708
|
+
logRefundError(logMessage, error) {
|
|
709
|
+
this.logger.error(logMessage, { message: error instanceof Error ? error.message : "unknown" });
|
|
714
710
|
}
|
|
715
711
|
/**
|
|
716
712
|
* Convenience wrapper composing `call` + `cachedCall`, so cached LLM calls
|
|
717
|
-
* automatically get retry/timeout/circuit-breaker behavior
|
|
718
|
-
*
|
|
713
|
+
* automatically get retry/timeout/circuit-breaker behavior. `reserveUsage`/
|
|
714
|
+
* `refundUsage` are read from the top-level params only.
|
|
719
715
|
*
|
|
720
|
-
*
|
|
721
|
-
* `call
|
|
722
|
-
*
|
|
723
|
-
* cachedLLMCall (once via cachedCall's wrapping, once via the inner call()).
|
|
716
|
+
* @param params - `cachedCall` params (`cacheKey`, `ttl`, etc, minus `fn`)
|
|
717
|
+
* plus `call`, the `CallParams` to pass through to `this.call(...)`.
|
|
718
|
+
* @returns The cached value on a hit, or the freshly-called result on a miss.
|
|
724
719
|
*/
|
|
725
720
|
async cachedLLMCall(params) {
|
|
726
721
|
const { call: callParams,...cacheParams } = params;
|
|
727
|
-
const { reserveUsage:
|
|
728
|
-
if (
|
|
722
|
+
const { reserveUsage: innerReserveUsage, refundUsage: innerRefundUsage,...restCallParams } = callParams;
|
|
723
|
+
if (innerReserveUsage || innerRefundUsage) this.logger.warn("[VernLLM] reserveUsage/refundUsage on `call` are ignored by cachedLLMCall; set them at the top level instead.");
|
|
729
724
|
return this.cachedCall({
|
|
730
725
|
...cacheParams,
|
|
731
726
|
fn: () => this.call(restCallParams)
|
|
732
727
|
});
|
|
733
728
|
}
|
|
734
729
|
/**
|
|
735
|
-
*
|
|
736
|
-
* circuit breaker was configured
|
|
730
|
+
* @returns The current circuit breaker state (`'closed' | 'open' |
|
|
731
|
+
* 'half-open'`), or undefined if no circuit breaker was configured.
|
|
737
732
|
*/
|
|
738
733
|
getCircuitState() {
|
|
739
734
|
return this.breaker?.getState();
|
|
@@ -790,15 +785,14 @@ function toAnthropicContent(blocks) {
|
|
|
790
785
|
* Wraps an Anthropic SDK client so it satisfies the same `LLMClient`
|
|
791
786
|
* interface VernLLM uses for OpenAI/Groq.
|
|
792
787
|
*
|
|
793
|
-
* `response_format: json_schema` is mapped to
|
|
788
|
+
* `response_format: json_schema` is mapped to Anthropic's forced tool-use:
|
|
794
789
|
* a single tool is defined with `input_schema` set to the caller's schema,
|
|
795
|
-
*
|
|
796
|
-
*
|
|
797
|
-
*
|
|
798
|
-
* gives, built on Anthropics tool-calling primitive instead).
|
|
790
|
+
* `description` forwarded when provided, and `strict` forwarded when set.
|
|
791
|
+
* `tool_choice` forces the model to call it. Provider-constrained schema
|
|
792
|
+
* matching applies only when `strict: true` is forwarded and supported.
|
|
799
793
|
*
|
|
800
794
|
* `response_format: json_object` (no schema to build a tool from) falls
|
|
801
|
-
* back to a system-prompt instruction, since
|
|
795
|
+
* back to a system-prompt instruction, since there's nothing to constrain
|
|
802
796
|
* generation against.
|
|
803
797
|
*/
|
|
804
798
|
function fromAnthropic(anthropicClient) {
|
|
@@ -809,11 +803,12 @@ function fromAnthropic(anthropicClient) {
|
|
|
809
803
|
let jsonInstruction;
|
|
810
804
|
let tools;
|
|
811
805
|
if (params.response_format?.type === "json_schema" && toolName) {
|
|
812
|
-
const { schema, description } = params.response_format.json_schema;
|
|
806
|
+
const { schema, description, strict } = params.response_format.json_schema;
|
|
813
807
|
tools = [{
|
|
814
808
|
name: toolName,
|
|
815
809
|
description,
|
|
816
|
-
input_schema: schema
|
|
810
|
+
input_schema: schema,
|
|
811
|
+
strict
|
|
817
812
|
}];
|
|
818
813
|
} else if (params.response_format?.type === "json_object") jsonInstruction = "Respond with valid JSON only, no prose or markdown fences.";
|
|
819
814
|
const system = [systemMessage?.content, jsonInstruction].filter(Boolean).join("\n\n");
|
|
@@ -855,7 +850,7 @@ function fromAnthropic(anthropicClient) {
|
|
|
855
850
|
/**
|
|
856
851
|
* Translates a VernLLM `ContentBlock[]` into Gemini's native `parts` array:
|
|
857
852
|
* text blocks become `{ text }`, image blocks become inline data parts
|
|
858
|
-
* (`{ inlineData: { mimeType, data } }`),
|
|
853
|
+
* (`{ inlineData: { mimeType, data } }`), Gemini's shape for embedding raw
|
|
859
854
|
* base64 image bytes directly in the request.
|
|
860
855
|
*/
|
|
861
856
|
function toGeminiParts(blocks) {
|
|
@@ -866,14 +861,14 @@ function toGeminiParts(blocks) {
|
|
|
866
861
|
}
|
|
867
862
|
/**
|
|
868
863
|
* Wraps a Gemini client so it satisfies the `LLMClient` interface VernLLM
|
|
869
|
-
* uses for OpenAI
|
|
870
|
-
* `contents` array instead of `messages`, a separate
|
|
871
|
-
* field instead of a `system` role message,
|
|
872
|
-
* top-level `temperature`/`max_tokens`, and
|
|
873
|
-
* `responseMimeType: 'application/json'` +
|
|
874
|
-
*
|
|
875
|
-
*
|
|
876
|
-
*
|
|
864
|
+
* uses for OpenAI-compatible APIs. Gemini's shape differs on nearly every
|
|
865
|
+
* axis: a `contents` array instead of `messages`, a separate
|
|
866
|
+
* `systemInstruction` field instead of a `system` role message,
|
|
867
|
+
* `generationConfig` instead of top-level `temperature`/`max_tokens`, and
|
|
868
|
+
* native JSON Schema support via `responseMimeType: 'application/json'` +
|
|
869
|
+
* `responseSchema`. `reasoning_effort` has no equivalent. Gemini's thinking
|
|
870
|
+
* models use a token budget, not an effort tier, so it's dropped, same as
|
|
871
|
+
* Anthropic.
|
|
877
872
|
*/
|
|
878
873
|
function fromGemini(geminiClient) {
|
|
879
874
|
return { chat: { completions: { async create(params, options) {
|
|
@@ -885,7 +880,13 @@ function fromGemini(geminiClient) {
|
|
|
885
880
|
maxOutputTokens: params.max_tokens
|
|
886
881
|
};
|
|
887
882
|
if (wantsJson) generationConfig.responseMimeType = "application/json";
|
|
888
|
-
if (params.response_format?.type === "json_schema")
|
|
883
|
+
if (params.response_format?.type === "json_schema") {
|
|
884
|
+
const { schema, description } = params.response_format.json_schema;
|
|
885
|
+
generationConfig.responseSchema = {
|
|
886
|
+
...schema,
|
|
887
|
+
...description ? { description } : {}
|
|
888
|
+
};
|
|
889
|
+
}
|
|
889
890
|
const response = await geminiClient.generateContent({
|
|
890
891
|
model: params.model,
|
|
891
892
|
contents: conversationMessages.map((m) => ({
|
|
@@ -942,39 +943,50 @@ function toBedrockContent(blocks) {
|
|
|
942
943
|
/**
|
|
943
944
|
* Wraps a Bedrock Converse-API client so it satisfies the `LLMClient`
|
|
944
945
|
* interface VernLLM uses for OpenAI/Groq. The Converse API is unified
|
|
945
|
-
* across
|
|
946
|
+
* across Bedrock's model families (Anthropic, Titan, Llama, Mistral, etc.),
|
|
946
947
|
* so unlike raw per-model Bedrock invocation, this one adapter works
|
|
947
948
|
* regardless of which underlying model `modelId` points at, as long as
|
|
948
949
|
* that model supports Converse (most current-generation ones do)
|
|
949
950
|
*
|
|
950
|
-
* `response_format: json_schema` is mapped to
|
|
951
|
-
* single tool is defined from the schema and
|
|
952
|
-
*
|
|
953
|
-
*
|
|
954
|
-
*
|
|
955
|
-
*
|
|
951
|
+
* `response_format: json_schema` is mapped to Converse's `toolConfig`: a
|
|
952
|
+
* single tool is defined from the schema, description, and strictness settings,
|
|
953
|
+
* and `toolChoice` forces the model to call it. Provider-constrained schema
|
|
954
|
+
* matching applies only when `strict: true` is forwarded and supported.
|
|
955
|
+
* Native tool support varies by model family; pass
|
|
956
|
+
* `toolUseSupportedModels` to preflight-check it (see
|
|
957
|
+
* `BedrockAdapterOptions`), otherwise a `jsonSchema` call to an
|
|
958
|
+
* unsupported model surfaces Bedrock's raw error unchanged.
|
|
959
|
+
*
|
|
956
960
|
* `response_format: json_object` (no schema to build a tool from) and
|
|
957
961
|
* `reasoning_effort` (no Converse equivalent) fall back to a system-prompt
|
|
958
962
|
* instruction and are dropped respectively.
|
|
959
963
|
*/
|
|
960
|
-
function fromBedrock(bedrockClient) {
|
|
961
|
-
|
|
964
|
+
function fromBedrock(bedrockClient, options) {
|
|
965
|
+
const toolUseSupportedModels = options?.toolUseSupportedModels;
|
|
966
|
+
return { chat: { completions: { async create(params, requestOptions) {
|
|
962
967
|
const systemMessage = params.messages.find((m) => m.role === "system");
|
|
963
968
|
const conversationMessages = params.messages.filter((m) => m.role === "user" || m.role === "assistant");
|
|
964
|
-
const
|
|
969
|
+
const jsonSchema = params.response_format?.type === "json_schema" ? params.response_format.json_schema : void 0;
|
|
970
|
+
const toolName = jsonSchema?.name.trim();
|
|
971
|
+
if (jsonSchema && !toolName) throw new LLMError("json_schema.name must not be empty.", "validation");
|
|
965
972
|
let jsonInstruction;
|
|
966
973
|
let toolConfig;
|
|
967
|
-
if (
|
|
968
|
-
const { schema, description } =
|
|
974
|
+
if (jsonSchema) {
|
|
975
|
+
const { schema, description, strict } = jsonSchema;
|
|
969
976
|
toolConfig = {
|
|
970
977
|
tools: [{ toolSpec: {
|
|
971
978
|
name: toolName,
|
|
972
979
|
description,
|
|
973
|
-
inputSchema: { json: schema }
|
|
980
|
+
inputSchema: { json: schema },
|
|
981
|
+
strict
|
|
974
982
|
} }],
|
|
975
983
|
toolChoice: { tool: { name: toolName } }
|
|
976
984
|
};
|
|
977
985
|
} else if (params.response_format?.type === "json_object") jsonInstruction = "Respond with valid JSON only, no prose or markdown fences.";
|
|
986
|
+
if (jsonSchema && toolUseSupportedModels) {
|
|
987
|
+
const isSupported = Array.isArray(toolUseSupportedModels) ? toolUseSupportedModels.includes(params.model) : toolUseSupportedModels(params.model);
|
|
988
|
+
if (!isSupported) throw new LLMError(`Bedrock model "${params.model}" is not listed in toolUseSupportedModels, but jsonSchema structured output requires Converse tool use.`, "validation");
|
|
989
|
+
}
|
|
978
990
|
const systemParts = [systemMessage?.content, jsonInstruction].filter((s) => Boolean(s));
|
|
979
991
|
const response = await bedrockClient.converse({
|
|
980
992
|
modelId: params.model,
|
|
@@ -988,7 +1000,7 @@ function fromBedrock(bedrockClient) {
|
|
|
988
1000
|
maxTokens: params.max_tokens
|
|
989
1001
|
},
|
|
990
1002
|
...toolConfig ? { toolConfig } : {}
|
|
991
|
-
},
|
|
1003
|
+
}, requestOptions);
|
|
992
1004
|
let text;
|
|
993
1005
|
if (toolName) {
|
|
994
1006
|
const toolUseBlock = response.output?.message?.content?.find((block) => block.toolUse?.name === toolName);
|
|
@@ -1201,6 +1213,8 @@ exports.CircuitBreaker = CircuitBreaker
|
|
|
1201
1213
|
exports.ConsoleLogger = ConsoleLogger
|
|
1202
1214
|
exports.InMemoryCacheAdapter = InMemoryCacheAdapter
|
|
1203
1215
|
exports.LLMError = LLMError
|
|
1216
|
+
exports.NormalizedCacheAdapter = NormalizedCacheAdapter
|
|
1217
|
+
exports.TieredCacheAdapter = TieredCacheAdapter
|
|
1204
1218
|
exports.VernLLM = VernLLM
|
|
1205
1219
|
exports.from01AI = from01AI
|
|
1206
1220
|
exports.fromAnthropic = fromAnthropic
|