Changelog
Release history for vern-llm
vern-llm uses Changesets for versioning. The release history below is generated automatically.
vern-llm
2.6.0
Minor Changes
-
b0b2249: Add
detectSoftFailure, a hook that can reclassify a technically successful response as a failure.A response can parse cleanly and pass schema validation without actually being a good answer: a placeholder string, an empty-but-present response, or a low-confidence refusal all look like successes to retries and the circuit breaker today.
detectSoftFailureruns once per attempt, right after a response is shaped, and lets you turn that result into a real failure before it reaches the caller:const llm = new VernLLM({ client: fromOpenAI(openai), model: 'gpt-4o', detectSoftFailure: (result, meta) => { if (typeof result === 'string' && result.trim() === 'N/A') { return 'soft_failure_detected'; } return undefined; }, });Returning
undefinedleaves the result as a success. Returning anLLMErrorCodefails the attempt with that code, feeding the same retry and circuit breaker paths a thrown error would. A throwing hook is caught, logged, and treated as no soft failure.fallbacktargets accept their owndetectSoftFailure, inheriting the parent instance's hook when left unset.A soft failure on a streaming call rejects
finalResultand counts toward the circuit breaker, same as a non-streaming failure would, even though the streaming attempt has already returned successfully from VernLLM's own retry loop by that point.Adds
soft_failure_detectedas a newLLMErrorCode. See the Error Handling docs for details. -
492d34e: Add
RateLimiterAdapter, a pluggable extension point sorateLimitcan be backed by a limiter that coordinates across processes, not just the built in in-processRateLimiter.Today,
rateLimitonVernLLMOptionsandFallbackTargetalways builds a fresh in-processRateLimiter. A second VernLLM instance, a second server process, or a horizontally scaled deployment each get their own independent bucket, so the real ceiling a provider enforces is never actually shared across any of them, and there was no way to plug in a limiter that is.import { VernLLM, type RateLimiterAdapter } from 'vern-llm'; const sharedLimiter: RateLimiterAdapter = new MyRedisBackedRateLimiter(/* ... */); const llm = new VernLLM({ client: fromOpenAI(openai), model: 'gpt-4o', rateLimit: sharedLimiter, });RateLimiterAdapteris the same surfaceRateLimiteralready exposes:estimate,acquire,signalRateLimit,reactToRateLimitHint. Handing over an object satisfying it, instead of a plainRateLimitOptionsconfig, is used as is, no wrapping. The same instance can be shared across the primary and any fallback target on purpose, e.g. two targets that really do draw on one provider account's real ceiling.Passing a plain
RateLimitOptionsobject (today's only option) keeps building an in-processRateLimiterexactly as before, zero behavioral change. -
e2aa869: Add
retryBudget, capping how much of a target's recent traffic is allowed to be retries, independent of the circuit breaker.maxRetriesonly bounds one call's own retries. A target can be perfectly healthy, never opening its breaker, while every single call still needs a retry, and today nothing catches that:const llm = new VernLLM({ client: fromOpenAI(openai), model: 'gpt-4o', retryBudget: { windowMs: 60_000, minCalls: 10, retryRatio: 0.1 }, });Once at least
minCallscalls have landed in the trailingwindowMsand the fraction of them that were retries reachesretryRatio, further retries against that target throwLLMError('rate_limited')withcode: 'retry_budget_exhausted'instead of retrying.minCallsgates the check the same way it already does fortripping: { kind: 'rolling', ... }, so a cold start with too little traffic to judge doesn't trip. Reuses the sameRollingRatioprimitiveRollingTrippingis built on.minCallsandretryRatioare validated at construction (a non-negative integer, and a finite number in[0, 1], respectively), thrown asRangeError.The breaker and the budget are independent gates asking different questions, breaker health vs retry cost, and never fire for the same reason: the breaker's gate runs once per logical call, before it starts; the budget's gate runs fresh at each retry, inside the call's own loop. A call can clear the breaker and still get cut off by the budget mid retry, distinguishable by
code(circuit_cooling_down/circuit_trial_in_flightvsretry_budget_exhausted).retryBudgetis built once per target, same ascircuitBreaker/rateLimit, andfallbacktargets get their own, never inherited from the parent. Every model routed through one target shares one budget, since a budget protects that target's real capacity regardless of which model a call asked for.llm.getRetryBudgetState(); // { attempts: 42, retryRatio: 0.07 } llm.getRetryBudgetState({ index: 1 }); // first fallback targetundefinedfor a target with no budget configured. OmittingretryBudgetentirely keeps today's exact behavior. Addsretry_budget_exhaustedas a newLLMErrorCode. See Retry Budget for details. -
2af8dda: Add
cooldownBackofftoCircuitBreakerOptions, growing the cooldown on each repeat open instead of using the same fixed wait every time.Today,
cooldownMsis a fixed value applied identically every time the circuit opens. A provider that keeps failing every cooldown period cycles through the same fixed wait forever.cooldownBackofflets that wait grow the more times the circuit reopens:const llm = new VernLLM({ client: fromOpenAI(openai), model: 'gpt-4o', circuitBreaker: { threshold: 5, cooldownMs: 30_000, cooldownBackoff: { multiplier: 2, maxMs: 5 * 60_000 }, }, });{ multiplier, maxMs }is shorthand for exponential growth, the shape most callers reach for, and always applies full jitter (randomized anywhere between zero and the full computed cooldown), so several client instances don't reopen in lockstep. ACooldownBackofffunction,(reopenCount, baseCooldownMs) => number, is the escape hatch for anything else, a linear ramp or an exact deterministic value, and is never jittered automatically.reopenCountonly increments on a trial that failed back to open, not on the first open from closed.Omitted (the default),
cooldownMsstays fixed, exactly reproducing today's behavior. -
282f147: Add
halfOpenProbesandhalfOpenSuccessRatiotoCircuitBreakerOptions, letting a half-open circuit admit more than one trial call before deciding to close or reopen.Today, exactly one trial call is let through once cooldown elapses: a single success closes the circuit, a single failure reopens it. That is a noisy signal for a provider whose failures are intermittent rather than total, one lucky or unlucky call decides the outcome.
halfOpenProbeslets several trials run, andhalfOpenSuccessRatiodecides how many of them need to succeed:const llm = new VernLLM({ client: fromOpenAI(openai), model: 'gpt-4o', circuitBreaker: { threshold: 5, cooldownMs: 30_000, halfOpenProbes: 3, halfOpenSuccessRatio: 2 / 3, // 2 of 3 trials succeeding closes the circuit }, });halfOpenProbesdefaults to 1 andhalfOpenSuccessRatiodefaults to 1, exactly reproducing today's single trial, must succeed behavior when both are left unset. Both are clamped at construction (halfOpenProbesto at least 1,halfOpenSuccessRatioto[0, 1]) rather than thrown, since a bad value here shouldn't take down the call path.A concurrent caller beyond the configured number of probes is still rejected with
circuit_trial_in_flight, same as today. -
deb48ee: Add AIMD (additive increase / multiplicative decrease) to
RateLimiter, letting a target's requests-per-minute ceiling grow gradually on success and shrink on a real rate limit rather than staying fixed:const llm = new VernLLM({ client: fromOpenAI(openai), model: 'gpt-4o', rateLimit: { requestsPerMinute: 500, aimd: { increaseBy: 10, decreaseFactor: 0.5, minCapacity: 50, maxCapacity: 1000 }, }, });increaseByis added to the ceiling on every clean release;decreaseFactormultiplies it down whenever the limiter reacts to a rate limit, bounded to[minCapacity, maxCapacity]. Reacting to a real 429 works for every provider unconditionally, no adapter changes required.fromOpenAIandfromAnthropiccan additionally react proactively, before a real 429 happens, by reading a provider's own rate limit headers off a successful response. This needs an explicit opt-in, since it depends on the underlying client supporting.withResponse():const llm = new VernLLM({ client: fromOpenAI(openai, { supportsWithResponse: true }), model: 'gpt-4o', rateLimit: { requestsPerMinute: 500, aimd: { increaseBy: 10, decreaseFactor: 0.5, minCapacity: 50, maxCapacity: 1000, proactiveFloor: 20, }, }, });With
proactiveFloorset, the limiter shrinks as soon as a response reports remaining capacity at or below that number, rather than waiting for an actual rejection.fromFetchgets the same proactive path with no opt-in needed (a newparseRateLimitHintconfig option, defaulting to OpenAI's header shape), since it already has direct access to the response headers.fromGeminiandfromBedrockget reactive-only AIMD: neither provider exposes a remaining-capacity header to react to proactively, confirmed against their own current documentation and SDK source, so only the real-429 path applies there.TokenBucket.resize()is new internal plumbing this relies on: a bucket's capacity can now change after construction, with its refill rate rescaled proportionally so a shrink doesn't leave the bucket refilling at its old, relatively-too-fast rate. -
4a54b37: Add
trippingtoCircuitBreakerOptions, so a circuit can open off a rolling failure ratio instead of only a fixed streak of consecutive failures.Today,
thresholdcounts consecutive failures and opens the circuit once that streak is reached. That's a poor fit for a provider whose failures are frequent but not literally back-to-back:trippinglets a caller open the circuit once a failure ratio is reached over a trailing window instead:const llm = new VernLLM({ client: fromOpenAI(openai), model: 'gpt-4o', circuitBreaker: { cooldownMs: 30_000, tripping: { kind: 'rolling', windowMs: 60_000, minCalls: 20, failureRatio: 0.5 }, }, });{ kind: 'consecutive', threshold }(the default, matchingthresholdon its own) opens after that many failures in a row.{ kind: 'rolling', windowMs, minCalls, failureRatio }opens once at leastminCallscalls have landed in the trailingwindowMsand the failure ratio among them reachesfailureRatio. A hand-builtTrippingPolicyis the escape hatch for anything else, no class required, a plain object satisfying the interface works:interface TrippingPolicy { onSuccess(key: string): void; onFailure(key: string): boolean; // true opens the circuit for key reset(key: string): void; forget?(key: string): void; // optional: called when key's bucket is discarded }keyis the resolved model underisolateByModel, or one fixed shared key otherwise. Exactly one instance of a policy is ever constructed, soisolateByModelisolation comes entirely fromkey: a policy that tracks its own state per key gets real per-model isolation automatically, no special handling needed, the same way the two built-in policies already do internally. A policy that ignoreskeyand tracks one flat counter stays intentionally shared across every model, a choice the policy makes rather than a limitation ofisolateByModelitself.onStateChange, thecircuit_stateevent, and the open-circuit error message still report a true consecutive-failure count regardless of which policy is configured, since that count is tracked independently of whatever a policy uses to decide when to trip.Omitted (the default), behavior is unchanged: consecutive-failure tripping against
threshold.{ kind: 'rolling', ... }'sminCallsandfailureRatioare now validated at construction:minCallsmust be a non-negative integer,failureRatioa finite number in[0, 1], both thrown asRangeErrorotherwise, the same waywindowMsalready is. Previously an out-of-range value silently produced a degenerate policy (always tripping or never tripping) instead of surfacing the mistake. Every value already in a valid range keeps working exactly as before.Also reorganizes
circuitBreaker.tsinto clearly labeled sections (options, cooldown backoff, tripping policy, bucket state, theCircuitBreakerclass), with the class's public API methods grouped separately from its private helpers. Pure code motion alongside the feature above: no additional behavior, type, or export changes. -
14c4797: Add
evictiontoInMemoryCacheAdapter, choosing between'fifo'(default) and'lru'oncemaxSizeis exceeded.Previously,
InMemoryCacheAdapteralways evicted the oldest inserted entry, regardless of how recently it was read. A key that's read constantly but written once still aged out on schedule alongside keys nobody had touched since.const cache = new InMemoryCacheAdapter(1000, 'lru');VernLLMOptions.cachealso gains a plain config shorthand, so the built-in adapter no longer needs an import or anew:const llm = new VernLLM({ client: fromOpenAI(openai), model: 'gpt-4o', cache: { maxSize: 1000, eviction: 'lru' }, });Passing a
CacheAdapterdirectly still works exactly as before,cacheaccepts either shape and resolves structurally. Omittingcacheentirely, or passingnew InMemoryCacheAdapter()(no second argument), keeps today's exact default: fifo eviction,maxSize1000.There's no custom eviction extension point beyond
'fifo'/'lru'. Anything past those two is a different cache backend throughCacheAdapter(a real Redis or Upstash instance, for example), not a third in-process algorithm.See Eviction for details.
-
d11ab7c: Add
getFailureBreakdowntoVernLLM,CircuitBreaker, and the internal call executor, exposing why a circuit's failures are happening rather than just how many.Today, a circuit's failure count is a single number: consecutive failures crossing
threshold. That number does not distinguish a run of timeouts from a run of 500s from a run of empty responses, all of which count the same way toward opening the circuit.getFailureBreakdownreports those reasons separately:llm.getFailureBreakdown(); // { server_error: 3, request_timeout: 1 } llm.getFailureBreakdown({ index: 1 }); // first fallback target llm.getFailureBreakdown({ model: 'gpt-4o' }); // for a target with isolateByModelIt takes the same
target: { index?, model? }shape asgetCircuitState, returnsundefinedfor a target with no breaker configured, and{}for a bucket that hasn't failed yet. A failure that carried noLLMErrorCodeattributes to'unknown'.The breakdown is attribution only, it never decides whether the circuit trips, and clears whenever the bucket does: on a successful call, a successful half-open trial, or a manual
closeCircuit().No breaking changes.
CircuitBreaker.recordFailure's existingcodeparameter, added in an earlier release to carry this data without a signature change, is now actually read.
Patch Changes
-
b0b2249: Fix: a
quota_exceededfailure no longer counts toward the circuit breaker.LLMError.countsTowardBreakeris now distinct fromLLMError.retryable. A usage reservation rejection is a caller or account level limit, not a signal that the provider itself is unhealthy, so repeatedquota_exceededfailures no longer push a healthy provider's circuit toward opening, even though they're still retried. Every other error type is unaffected.CircuitBreaker.recordFailurealso accepts a new optional fourth argument, the failing error'sLLMErrorCode. It isn't read yet, existing calls are unaffected, and this lands ahead of upcoming circuit breaker attribution work. -
96a1aea:
getBackoffDelaynow uses full jitter instead of equal jitter for retry backoff delays.The computed delay is now randomized anywhere between zero and the full exponential value,
random() * exp, instead ofexp / 2 + random() * (exp / 2). AWS's own analysis found full jitter does less client work and completes retries faster than equal jitter under contention, since it spreads retries over a wider window instead of clustering them in the top half of the range. See Exponential Backoff and Jitter.This changes the actual delay values retries wait for, but not the retry logic itself, no options or public API changed.
2.5.0
Minor Changes
-
03bb03d: Computed backoff now differs by failure type when no
Retry-Afterheader is present. A rate-limited (429) response backs off hardest, a server error (500 through 599) backs off somewhat more than the default curve, and every other retryable failure keeps the default curve.getBackoffDelaygains two new optional parameters,rateLimitedandserverError, both defaulting tofalse, so any existing caller passing neither keeps today's exact behavior. -
1356095:
call()andcachedCall()now accept an optionaldeadlineMs, a total time budget for the whole logical call spanning every retry and every fallback target, unliketimeoutMswhich resets on each attempt. OncedeadlineMselapses, the call is aborted withLLMError('aborted', { code: 'deadline_exceeded' }), distinguishable from an abort caused by a caller-suppliedsignal. Purely additive: omittingdeadlineMsleaves existing behavior unchanged. -
d633815: Adds middleware support via a new
middlewareoption onVernLLMOptions. Each entry cantransformthe outgoing wire request per attempt (patches, not full replacement;addMessages/addToolsappend without clobbering another middleware's own additions),wrapone whole logical call exactly once regardless of how many retries or fallback targets ran underneath it (with the ability to short-circuit the real call entirely), observe the same events reported ononEventvia its ownonEvent, and gate itself per call viaenabled.wrapandtransformcompose across several middleware inpriorityorder, and can coordinate through a typed, collision-proofctx.state(see the newcreateStateKey). A newcreateMiddlewarehelper adds anonErrorconvenience on top ofwrapfor the common "I only care about failures" case. A newmiddlewareTimeoutMsoption (default 5000) boundstransformand a functionenabled; values<= 0disable the timeout (unbounded).wrapitself is intentionally never bounded by it, since it legitimately spans the whole call.MiddlewareContextis now a discriminated union,AttemptContext | PreDispatchContext, tagged byctx.stage.transform's ownctxnarrows toAttemptContext(requestedProvider/requestedModel/isFallbackAttempt/attempt, all accurate to the real target for that attempt).wrap's ownctx(andcreateMiddleware'sonError, built fromwrapinternally) narrows toPreDispatchContext, which only carriesprimaryProvider/primaryModel: there is no real target yet whenwrapruns, so the placeholderisFallbackAttempt/attemptfields from the previous single-shapeMiddlewareContextare gone rather than silently always reportingfalse/1.enabledandonEventare called from both stages, so they keep receiving the fullMiddlewareContextunion and narrow onctx.stagewhen they need a stage-specific field.dispatchEventToMiddlewarealso now catches a rejected asynconEvent, not just a synchronous throw, logging it the same way instead of leaving an unhandled rejection.Purely additive to
VernLLMOptions/call()/cachedCall()themselves:middlewaredefaults to an empty array, and no existing option or method changes shape.MiddlewareContext's own shape does change, as described above. The only migration needed is awrap/onErrorimplementation that readctx.requestedProvider/requestedModel/isFallbackAttempt/attemptdirectly, which should switch toctx.primaryProvider/primaryModel(or readresult.metaafternext()resolves for the real target).
Patch Changes
-
0b8653d: Retry After parsing now checks millisecond headers (
Retry-After-Ms,X-Retry-After-Ms) some providers send in addition to the standardRetry-Afterheader, accepts a decimal seconds value, and treats a negative delta or a past HTTP date as absent instead of clamping it to an immediate 0ms retry. -
583eac0: Two type-only additions from the CallExecutor refactor plan, no behavior change.
Added
metaRef(), a small helper that returns{}typed as{ current?: CallMeta }, for use asCallParams['meta']. Saves writing that type out by hand when reading the target that answered offcall()'smetaout-parameter. A hand-written{ current?: CallMeta }literal still works exactly the same.Added
LLMRequestShape<T, Tools>, the request-only fields a call takes, without thereserveUsage/refundUsagehooks fromUsageHooks.CallParams<T, Tools>is now defined asLLMRequestShape<T, Tools> & UsageHooks, andCachedCallParams,CachedToolCallParams,CachedConditionalToolCallParams,CachedJsonModeDisabledCallParams,CachedJsonModeEnabledCallParams,CachedStreamCallParams,CachedStreamToolCallParams,CachedStreamConditionalToolCallParams,CachedStreamJsonModeDisabledCallParams, andCachedStreamJsonModeEnabledCallParamsare now derived fromLLMRequestShapedirectly instead of each separately re-derivingOmit<CallParams<T>, 'reserveUsage' | 'refundUsage'>. No field on any of these types changes shape;LLMRequestShapeis also exported for anyone who wants the request shape on its own.
2.4.2
Patch Changes
-
25677cc: Anthropic and Bedrock now classify
toolscombined withjsonSchemaon models outsidenativeStructuredOutputModelsasLLMError('invalid_params')withcode: 'unsupported_capability'andissues: { capability: 'tools_with_json_schema' }. The existingdefaultFallbackOnpolicy can therefore continue to the next target instead of stopping on the adapter's local capability restriction. -
f9dbdfe: Fixed two bugs in the Gemini adapter's tool call handling.
Parallel calls to the same tool in one turn no longer throw a spurious
duplicate_tool_call_idvalidation error. The adapter previously built every wire tool call id from the function name alone, so two calls toget_weatherin the same turn always collided. Gemini 3 and later now populate a native, uniqueidon everyfunctionCall, and the adapter uses it when present. On models before Gemini 3 that omit it, an id is synthesized from the function name plus how many times that name has already appeared in the response, so repeated calls to the same tool still get distinct ids.functionResponse.namesent back to Gemini is now resolved from the assistant turn's own prior tool call, instead of being assumed equal to the wire tool call id. The old assumption only held because ids were always synthesized from the name; it silently sent the wrong function name whenever a native id didn't match the name string.No change to
WireToolCall,ToolCall, or any other adapter. Every fix stays insideadapters/gemini.ts. -
d8fa998:
buildStreamResultnow bounds its streamedtool_call_deltaaccumulator.toolCallAccaccepts at most 10,000 distinct tool-call indices, and each entry retains at most 1,000,000 argument characters. Both checks run before inserting a new map entry or appending to an existing argument string, so a misbehaving provider cannot grow either structure without limit.Exceeding either limit throws
LLMError('validation')through the existing stream failure path. The iterator is cleaned up, the stream is aborted, and bothchunksandfinalResultobserve the normalized failure instead of allowing the accumulator to continue consuming provider output. -
64010df:
ToolCall.argumentsis now typed per tool instead ofunknown, when TypeScript can see the exact tools passed tocall()/cachedCall().ToolDefinitionis generic over the tool'snameand itsargumentsSchema's inferred argument type. A newdefineTool()helper preserves a tool's literalname(without requiringas const), which is what lets aToolCallbe matched back to the tool that produced it:import { z } from 'zod'; import { defineTool } from 'vern-llm'; const weatherTool = defineTool({ name: 'get_weather', description: 'Gets the current weather for a city', parameters: { type: 'object', properties: { city: { type: 'string' } }, required: ['city'], }, argumentsSchema: z.object({ city: z.string() }), }); const result = await llm.call({ userContent: 'What is the weather?', tools: [weatherTool], }); if (result.type === 'tool_calls') { const call = result.toolCalls[0]; call.arguments.city; // typed as string, no cast or re-parse needed }With more than one tool in the array,
argumentsis a discriminated union keyed byname; narrowing oncall.name(if (call.name === 'get_weather')or aswitch) is required before accessing a tool-specific field, the same wayisToolCallResult()narrowscall()'s own result union today.This only applies when TypeScript can see the exact tools at the
call()/cachedCall()call site. A plainconst tools = [weatherTool, cancelOrder]variable (assigned once, not conditionally, without its own type annotation) still narrows correctly when passed through, same as an inline array literal. Conditional tools (tools: someCondition ? [weatherTool] : undefined) also narrow correctly:isToolCallResult()is now generic and infersToolsfrom the result automatically in this case, no type argument needed.argumentsfalls back tounknownin three cases, all variations on TypeScript no longer having the literal tool objects to work with: thetoolsvariable itself is explicitly annotated (const tools: ToolDefinition[] = [...]), the params object carries an explicit: CallParams<T>annotation, orTis pinned explicitly (call<string>(...)) alongside a literaltoolsarray, TypeScript's own generic inference rules suppress inference for every subsequent type parameter once any leading one is explicit, not something specific to this library. PassToolsexplicitly toisToolCallResult<typeof tools>()(for the first two cases) or tocall<T, Tools>()/cachedCall<T, Tools>()(for the third), or route throughdefineCallParams()instead of a:annotation, to recover typing in each case. Nothing about runtime behavior changes: validation, parsing, and error handling for tool arguments are unaffected. -
a14dd2a: Improve TypeScript inference for calls that combine
jsonMode: falsewith conditionaltools.call()andcachedCall(), with or withoutstream: true, now infer string content for the non-tool result without requiring an explicit response type, while preserving the wrapped content and tool-call result union when tools are present.The conditional string-tool parameter shapes are exposed as named helper types for reuse:
ConditionalStringToolCallParams,CachedConditionalStringToolCallParams,StreamConditionalStringToolCallParams, andCachedStreamConditionalStringToolCallParams.
2.4.1
Patch Changes
-
dee6dbc: Fix the published bundle shipping unminified:
tsdown.config.tswas missingminify: true, so the package shipped full source with comments instead of a minified build (~206 kB / ~59 kB gzipped instead of the intended ~72 kB / ~21 kB gzipped).While fixing this, also enabled
publintandunusedchecks in the build and resolved what they found:- Fixed
exports["."].typesto resolve correctly under bothimportandrequireconditions (previously CJS consumers usingrequire()with TypeScript could get the wrong types). - Added
"sideEffects": falseso bundlers can tree-shake the package. - Fixed
repository.urlto a full git URL. - Pinned
unplugin-unusedto^0.4.4to match the peer rangetsdown@0.9.9actually requires.
No public API changes.
- Fixed
2.4.0
Minor Changes
-
411164c: A round of fixes and additions from a hands-on DX report exercising every built-in adapter end-to-end. One real build-level bug, one error-message improvement, and one new capability (with matching pre-flight validation) that came directly out of hitting these while wiring up a real multi-provider example app.
Fixed:
fromBedrock's raw-AWS-SDK-client path threwTypeError: ConverseCommand is not a constructor(or the same forConverseStreamCommand) on every call. The published build was bundling@aws-sdk/client-bedrock-runtimeinto a local chunk instead of leaving the dynamicimport('@aws-sdk/client-bedrock-runtime')inwrapAwsSendClientas a genuine runtime import resolved from the consumer's ownnode_modules. The bundler's CJS interop for that inlined chunk only produced adefaultexport, not real named exports, so destructuringConverseCommand/ConverseStreamCommandoff the resolved module returnedundefinedfor both.@aws-sdk/client-bedrock-runtime(and every other provider SDK package, defensively, even though none of the others are currently dynamically imported) is now markedexternalin the build config, so it's never bundled. This also restores the documented zero-runtime-dependency guarantee for this path: the AWS SDK was being silently embedded (~937KB) into every install regardless of whether this path was ever used.Improved:
LLMError.messagefor provider API errors (type: 'api') now includes the provider's own error description instead of always being the generic"LLM request failed".describeError()already existed internally and correctly extracted a provider's error body (it was previously only used fordebug: truelogging); that detail is now folded into the thrown error's own.messageunconditionally. When a provider genuinely returns no error detail at all (a non-2xx response with an empty body, which some providers do for certain field-validation failures, e.g. sendingreasoning_effortto a model that doesn't support it), the message now says so explicitly and points at the likely cause, instead of falling back to the same uninformative string every other API error got:LLM request failed with status 400 and no error detail from the provider. This usually means a field or value in the request isn't supported by the specific model (for example, a reasoning/thinking parameter the model doesn't accept), rather than a transport or auth problem.status,code,cause, and every other field onLLMErrorare unchanged, this only affects.messageontype: 'api'errors. If you were matching on the exact previous message, match on.type === 'api'and.statusinstead, both unaffected and always the more precise way to branch on this.Added:
budgetTokens/reasoningEffortnow acceptnullto explicitly skip an instance-leveldefaultBudgetTokens/defaultReasoningEffortfor one call, mirroring the existingtemperature: number | nullpattern. Previously there was no way to say "not for this call" once an instance default was set, onlyundefined(defer to the instance default) or a real value (override it).Added: Anthropic (and Claude models on Bedrock) now pre-validate
budgetTokens/reasoningEffortcombined with a forcedtoolChoice. Anthropic rejectsthinking(manual or adaptive) alongside atool_choicethat forces tool use, a forced single tool or'required', with a 400:"Thinking may not be enabled when tool_choice forces tool use."This is now caught locally and thrown asLLMError('invalid_params')before any request is sent, the same treatmentbudgetTokens >= maxTokensalready got. This also covers the implicit case wherejsonSchemasilently forces a single synthetic tool call to emulate structured output on a model without native support, even with notoolChoiceof the caller's own set.The last two land together because the second directly motivated the first: an instance-wide
defaultBudgetTokensused to make every forced-tool-choice call on that instance fail, with no way to opt just that one call out short of dropping the instance default entirely.null-override-and-forced-tool-choice.ts const llm = new VernLLM({ client: fromAnthropic(anthropic), model: 'claude-sonnet-4-6', defaultBudgetTokens: 1024, // reasoning on by default }); // Throws LLMError('invalid_params') before any request is sent: forced // toolChoice + budgetTokens (from the instance default) is a real // Anthropic-side conflict. await llm.call({ userContent: 'summarize', tools: [summarizeTool], toolChoice: { name: 'summarize' }, }); // Fixed: explicitly opt this one call out of the instance-level reasoning // default instead of dropping it for every call. await llm.call({ userContent: 'summarize', tools: [summarizeTool], toolChoice: { name: 'summarize' }, budgetTokens: null, }); -
c203fb2:
response_format: { type: 'json_object' }is no longer emulated onfromAnthropic/fromBedrockvia an unenforced system-prompt instruction ("Respond with valid JSON only, no prose or markdown fences."). Neither provider has a request field that mechanically guarantees JSON output for this mode. That instruction was a weaker guarantee than the type implied: the model could still ignore it, wrap output in prose, or add a markdown fence. This was also the one place in the codebase where an unsupported provider feature was silently degraded instead of failing loudly, unlike every other cross-provider gap (e.g.jsonSchema+toolson an unsupported model already throws instead of degrading).Both
LLMClientimplementations now report this via a newsupportsJsonObjectMode?: booleanfield (defaults totruewhen omitted; every OpenAI-compatible client andfromGeminiare unaffected, since both mapjson_objectto a real API field).fromAnthropicandfromBedrockset it tofalse, andRequestBuilder's behavior branches on it:- An explicit
jsonMode: true(orVernLLM.call({ jsonSchema })isn't set and you asked for JSON directly) on a client withsupportsJsonObjectMode: falsenow throwsLLMError('invalid_params')before the request is ever sent, naming the client and pointing atjsonSchemaas the real alternative. - The default, unset
jsonMode, with noschemato validate against, the common case of a plainllm.call({ userContent }), is silently downgraded to plain text instead of throwing. This keeps a barellm.call({ userContent })working exactly as before on Anthropic/Bedrock, for callers who never actually wanted JSON and were just getting the library's default. schemawithoutjsonSchemaalways requires real JSON output to validate against, so it always throwsLLMError('invalid_params')onsupportsJsonObjectMode: falseclients, naming the real cause, whetherjsonModewas set explicitly or left at its default. An implicit request for JSON (viaschema) is deliberately not silently downgraded the way a schema-less call is: doing so would skip validation entirely while reporting success.jsonSchemais unaffected either way: it was never routed throughjson_object, and maps to a real API-level constraint on both providers (native structured output or a forced single tool call).
If you were relying on
jsonMode: true(explicitly or by default) to get JSON-shaped text on Anthropic/Bedrock without ajsonSchema, switch tojsonSchema. It's a strictly stronger guarantee anyway. - An explicit
-
6d0bcdb:
fromBedrocknow accepts a raw AWS SDK v3 client directly. It takes either a hand-writtenBedrockConverseClient(.converse()/.converseStream()) or a realBedrockRuntimeClient(anything with.send()), and detects which one it got. No wrapper is required for the latter:import { BedrockRuntimeClient } from '@aws-sdk/client-bedrock-runtime'; import { VernLLM, fromBedrock } from 'vern-llm'; const client = new BedrockRuntimeClient({ region: 'us-east-1' }); const llm = new VernLLM({ client: fromBedrock(client), model: 'anthropic.claude-3-5-sonnet-20241022-v2:0', });vern-llmstill has zero runtime dependencies.@aws-sdk/client-bedrock-runtimeis not a dependency, not even a peer dependency. A raw AWS client pulls inConverseCommand/ConverseStreamCommandwith a dynamicimport()on the first real request, not whenfromBedrockis called. A missing install throws a clearLLMErrornaming what's missing.Also fixed two typing gaps between AWS's generated types and
BedrockConverseClient, previously bridged with a plainasassertion:- AWS types
ConverseStreamCommandOutput.streamas optional.BedrockConverseClient's ownconverseStreamalways returns{ stream: AsyncIterable<...> }. A response missingstreamnow throws a clearLLMError('api')instead of crashing the internalfor awaitloop. - AWS's real streaming event union includes a generated
$unknownmember VernLLM doesn't model. Every event is now narrowed through an explicit check first. Anything unmodeled, including$unknown, is dropped rather than forwarded.
fromBedrock(converseClient)with a hand-writtenBedrockConverseClientis unaffected. It's still the zero-dependency option for a different AWS SDK generation or a hand-rolled HTTP client. - AWS types
-
4756645:
call()andcachedCall()return better types for JSON mode.Before,
jsonMode: falsestill typed the result asunknown, even though the runtime value was always astring:const response = await llm.call({ userContent: 'Hello', jsonMode: false, }); // response: unknown, but really a stringNow the return type matches the requested mode:
const response = await llm.call({ userContent: 'Hello', jsonMode: false, }); // response: string const parsed = await llm.call({ userContent: 'Hello', jsonMode: true, }); // parsed: JsonValueJsonValueis a new exported type for any valid JSON shape:type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };A call site that also sets
schemastill getsTinferred from the schema, exactly as before. This overload change only affects calls that don't useschema. Streaming calls (stream: true) get the same treatment:finalResultnow resolves tostring/JsonValueinstead ofunknownfor the same twojsonModecases, for bothcall()andcachedCall().ConversationTurnassistantcontentnow also accepts a parsedJsonValue, so ajsonMode: trueresult can be pushed straight intohistorywithout stringifying it yourself:import type { ConversationTurn } from 'vern-llm'; const history: ConversationTurn[] = []; const parsed = await llm.call({ userContent: 'Give me a JSON summary.', jsonMode: true, }); history.push( { role: 'user', content: 'Give me a JSON summary.' }, { role: 'assistant', content: parsed }, );VernLLM's request construction
JSON.stringifys non-string assistantcontentbefore it's sent as part of the wire request, so no manual stringifying is needed on the caller's side. -
b3a5de4: Added
budgetTokensonCallParams, a numeric reasoning budget alongside the existingreasoningEfforttier string.Each adapter reads its own native field first. Anthropic and Gemini use
budgetTokensdirectly. OpenAI compatible clients usereasoningEffortdirectly. Bedrock forwards a budget only for Claude models. When only the other field is set, it is converted through a shared table, documented inadapters/internal/reasoningBudget.utils.ts.Also added
reasoningTokensonTokenUsage, a subset ofcompletionTokens, populated whenever the provider reports a separate figure for internal reasoning. Undefined for Bedrock today, since Converse only returns that figure if the request explicitly asks for it, a separate feature outside this change.Added
defaultReasoningEffortanddefaultBudgetTokensonVernLLMOptionsandFallbackTarget, matching the existingdefaultTemperaturepattern. Resolution order is per call value, then the fallback target's own default, then the instance default.Added
reasoningEffortTokensas an option onfromAnthropic,fromGemini,fromOpenAICompatible, andfromBedrock, letting the conversion table itself be overridden per adapter instance. Only the tiers listed are changed, any tier left out keeps the built in default.Neither field is required. Existing calls and instances that set nothing here are unaffected.
See the
budgetTokensrow in the Call Params reference for full per provider behavior. -
d420f6f:
fromGemininow accepts the whole@google/genaiclient, not justai.models, and unwraps.modelsinternally:import { GoogleGenAI } from '@google/genai'; import { VernLLM, fromGemini } from 'vern-llm'; const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY }); const llm = new VernLLM({ client: fromGemini(ai), model: 'gemini-2.5-flash', });fromGemini(ai.models)still works exactly as before.GeminiClientnow models both shapes itself, via an optional self-referencingmodels?: GeminiClientfield, so there's nothing new to import: passingaidirectly, orai.models, both type-check against the realGoogleGenAIclient with noas GeminiClient/as unknown ascast required anywhere. Previously,GeminiClientdiverged from the real SDK's generated types onmodel(optional here vs. required there),functionCall.args/functionResponse.response(unknownhere vs.Record<string, unknown>there), andtoolConfig.functionCallingConfig.mode(a plain string union here vs. a real string enum there, which TypeScript never treats as structurally compatible); all three are now aligned.Behavior change:
parseToolResult(used forrole: 'tool'messages) now always produces an object for Gemini'sfunctionResponse.response, matching the real SDK'sRecord<string, unknown>requirement there. A tool result whosecontentparses to something other than a plain JSON object, a bare string, number, array, or unparseable text, is now wrapped under anoutputkey (e.g.content: '"sunny"'sends{ output: 'sunny' }instead of the bare string'sunny'). Tool results that are already JSON objects are unaffected.fromGemininow throwsLLMError('invalid_params')immediately if the client it's given, and its.modelsif present, has nogenerateContent(orgenerateContentStream, forstream: truecalls) that's actually a function, instead of deferring to a confusing nativeTypeErroron the first real call. -
adca7fb: Every recorded
RetryAttemptnow also carriesrequest, a snapshot of what was actually sent for that attempt, sitting next to the existingerrorsnapshot of what came back:try { await llm.call({ userContent: 'Hello' }); } catch (err) { if (isLLMError(err)) { for (const attempt of err.attempts ?? []) { console.log(attempt.error.message, attempt.request?.body); } } }requestis anLLMRequestSnapshot:interface LLMRequestSnapshot { provider: string; model: string; body: unknown; headers?: Record<string, string>; startedAt: number; }Auth headers (
Authorization,x-api-key,x-goog-api-key,api-key) are always stripped before the snapshot is built, case insensitively, so they never end up inheaders.requestis optional and additive. It isundefinedon attempts recorded before this field existed, andFallbackAttemptpicks it up for free since it already extendsRetryAttempt. No existing field, method, or constructor option changes shape.
Patch Changes
-
40212b7:
call()/cachedCall()(including theirstream: truevariants) no longer silently mistype atool_callsresult as plain content whentoolsis set conditionally:const tools = condition ? [myTool] : undefined; const result = await llm.call({ userContent, tools }); // Before: typed `unknown` (or whatever T you asked for), even though the // runtime value could genuinely be a `tool_calls` result once `tools` was // actually an array at call time. // Now: typed `T | CallWithToolsResult<T>`, so `isToolCallResult(result)` // is required (and correctly enforced) before treating it as plain content.The runtime behavior was already correct:
call()has always returned a realCallWithToolsResult<T>whenever the model was actually offered tools, regardless of whethertoolswas a literal array or a variable. Only the type was wrong,tools: ToolDefinition[] | undefinedmatched neither the tools-enabled nor the tools-disabled overload, so TypeScript fell through to the final generic overload and typed the result as plainT.This adds a
ConditionalToolCallParams<T>overload (and cached/streaming counterpartsCachedConditionalToolCallParams<T>,CachedStreamConditionalToolCallParams<T>) that catches this shape and returns the honest union instead. It's picked up automatically; no code changes are needed to benefit from it, as long astools's narrower type reachescall()intact (an inline literal, or a variable that hasn't been widened by an explicit: CallParams<T>annotation, see below). Call sites using a literaltools: [...]array are unaffected and keep inferringCallWithToolsResult<T>directly, same as before. Call sites that omittoolsentirely are also unaffected, since tools genuinely cannot have run there.New exports:
defineCallParams()/defineCachedCallParams(). A: CallParams<T>variable annotation widenstoolsaway before it ever reachescall(), no overload fix can recover from that (it's how TypeScript's type annotations work, not a gap in this library). These two are identity functions, return exactly what you pass them, for building acall()/cachedCall()params object in a named, reusable variable without hitting that trap:import { defineCallParams } from 'vern-llm'; const params = defineCallParams({ userContent: 'What is the weather?', tools: someCondition ? [weatherTool] : undefined, }); const result = await llm.call<string>(params); // result: string | CallWithToolsResult<string> (defaults to unknown if T // isn't pinned via call<T>() or schema)They work by giving
P(the whole params object) a single, plain generic parameter, noconsttype parameter needed. TypeScript 5.0'sconsttype parameters would also solve this, but they'd silently raise this package's effective minimum TypeScript version (this package declares none today, and the whole package's.d.tswould fail to parse on TypeScript<5.0, not just these two functions), and turned out to be unnecessary here anyway:tools: someCondition ? [tool] : undefined's type is already the union the ternary computes, not a literal that needsconstto avoid being widened.Tisn't a parameter ofdefineCallParams()itself; pin it the normal way, viallm.call<T>(params), exactly as you would with an inline object.satisfies CallParams<T>remains a valid, import-free alternative todefineCallParams()for the same purpose.isToolCallResult()was already the documented way to narrow a dynamically-typed result; this change makes TypeScript require that check for the conditional-tools case instead of only recommending it in a doc comment.
2.3.0
Minor Changes
-
a441b47:
LLMErrornow definestoJSON(), controlling whatJSON.stringify(err)produces:name,message,type,status,issues,retryAfterMs,code,retryable,attempts.LLMErrorSnapshot(the shape ofRetryAttempt.error, also whattoSnapshot()returns) carries the same fields exceptname:message,type,status,issues,retryAfterMs,code,retryable,attempts.causeis left out of both, deliberately.causeisunknownand never validated by VernLLM, so it is the one field onLLMErrorthat is not guaranteed safe to hand toJSON.stringify: some SDKs throw errors with circular internal references, whichJSON.stringifycannot serialize.err.causeon the live, just-caught error is unaffected and still holds the exact original value. That is wherecauseis meant to be read, on the spot, not folded into serialization or carried inside retry history.messageandretryableare included even though a plain property walk would miss both:messageis non-enumerable onError, andretryableis a getter, not an own property.issuesis included, but not unconditionally. For a schema validation failure,issuesis a caller suppliedSchemaLikevalidator's ownerror: unknown, not controlled by VernLLM and not guaranteed circular free. A circularissuesvalue is replaced with an explicit marker string in the serialized output, rather than dropped silently or left to throw.err.issueson the live error is unaffected. This check runs recursively through every nestedattemptsentry's ownissuestoo, not just the top levelissues, sinceattemptsis itself a public constructor option a caller can hand build, and a previously safeissuesreference on a snapshot can be mutated into a circular one later. -
7af6ecd: Reworked the
LLMErrortaxonomy sotypestays a small, closed set a caller can exhaustivelyswitchover, whilecodecarries the specific reason underneath it. This is a breaking change to several already-shippedtype/codevalues, accepted on minor sincevern-llmis still in beta:- Tool contract failures (
unknown_tool,duplicate_tool_call_id,tool_choice_none_violated, and the previously uncoded "provider returnedtool_callswith notoolssent" case, nowcode: 'unexpected_tool_calls') move fromtype: 'api'totype: 'validation'. They're a provider contract violation, not an HTTP failure. - A new
type: 'invalid_params'splits off fromtype: 'validation'for every check that's deterministic on the caller's own input and never touches the network: therequestBuilder.tschecks (empty/duplicate tools,toolChoiceissues, schema/jsonModeconflicts, history ordering), thecachedCallreserveUsage/refundUsageguard,imageFormat.ts's mimeType check, and "no provider targets configured." A newcode: 'unsupported_capability'covers the one pattern repeated across adapters:stream: trueagainst a client with no streaming method, a model outsidetoolUseSupportedModels, ortoolChoice: 'none'against a provider with no equivalent. - Local rate-limit rejections move from
type: 'quota_exceeded',code: 'local_rate_limit'totype: 'rate_limited', split into three specific codes:rate_limit_queue_full,rate_limit_queue_timeout, andrate_limit_capacity_exceeded. The old single code couldn't tell a caller whether waiting and retrying later was worth attempting;rate_limit_capacity_exceedednever will, the other two usually will once load drops.type: 'quota_exceeded'now means only what it originally described: areserveUsagehook rejecting the call. code: 'invalid_credentials'is replaced byauthentication(401) andauthorization(403), so a caller can tell a missing key apart from a key that lacks access without VernLLM inventing a newtypefor every HTTP status. New codesnot_found(404),payload_too_large(413),server_error(5xx), andempty_responseround out the HTTP-status-derived set.- A new
type: 'network'withcode: 'connection_failed'separates transport-level failures (DNS, connection refused, connection reset) from the catch-alltype: 'unknown'. FallbackExhaustedError.typeis now always'fallback_exhausted', its own identity, instead of inheriting the last attempted target'stype.statusandretryAfterMsstill inherit from the last attempt.
Added
LLMError.retryable, computed purely fromtype/code, independent of any specific call'snonRetryableStatuslist or its signal's abort state.FallbackExhaustedErroroverrides it to defer to the last attempted target's ownretryable, sincetype: 'fallback_exhausted'alone carries no retry signal.LLMError's constructor collapses everything aftertypeinto one optional object:new LLMError(message, type, { status, code, issues, cause, retryAfterMs }). Previously these were five more positional parameters, so a throw site that only neededcodestill had to write fourundefineds to reach it. This breaks anynew LLMError(...)call site outside the package itself, including a customLLMClientadapter or a subclass callingsuper()positionally. The break is narrower than it sounds, since callers normally only catchLLMError, not construct it, but worth naming directly: the last time this constructor grew, the explicit goal was that every existing call site would keep compiling unchanged, and this change knowingly reverses that.Removed the deprecated
toolIssuesgetter/setter onLLMError.issuesis now the only place tool contract problems (or a schema validator's error object) are carried; readerror.issuesinstead oferror.toolIssues.LLMError.retryablenow also returnsfalsefortype: 'aborted', matching the taxonomy's own type table (previously an aborted error would reportretryable: true, which contradicted intentional-cancellation semantics).CallExecutor's internal retry/circuit-breaker accounting is unaffected, since an aborted signal was already checked separately before either ever consultedretryable.issuesgained real types instead of being blanketunknown.LLMErrorIssuesByCodemaps every code that carries structured data to its exact shape, and a newhasIssues(err, code)type guard narrowserr.issuesoff that samecodewith no manual cast:if (isLLMError(err) && hasIssues(err, 'duplicate_tool_names')) { console.log(err.issues.names); // string[], fully typed }Five new codes back this:
duplicate_tool_namesandunknown_tool_choice(bothrequestBuilder.tschecks that previously threw uncoded), andduplicate_tool_result_ids/unknown_tool_result_ids/missing_tool_results(the threehistory"tool" turn checks, also previously uncoded). All five, plus the existingunsupported_capability, now carry a typedissuespayload built from the same list themessagealready string-joins, rather than making a caller re-parse it out of prose.unknown_tool/duplicate_tool_call_id(ToolIssue[]) are unchanged, just added to the same lookup table. Every otherinvalid_paramscheck stays uncoded and withoutissues, since it's a single deterministic fact themessagealready states in full. Schema-validation failures (type: 'validation', nocode) are the one deliberate exception left asunknown: that payload is the caller's own Zod-compatible validator's error object, a shape VernLLM can't know in advance. - Tool contract failures (
-
532ebf4: LLMError now carries an optional attempts array, one entry per attempt made against the current target before the error was thrown, each with that attempt's index and a snapshot of that attempt's error. Absent when nothing was retried.
Added RetryAttempt, the shape of each entry, and LLMErrorSnapshot, the shape of
RetryAttempt.error: an inert, point-in-time copy of an LLMError's fields (message, type, code, status, issues, retryAfterMs, cause, retryable), produced by the newLLMError.toSnapshot(). A recorded attempt is a record, not a live, throwable error, soRetryAttempt.erroris a snapshot rather than anLLMErroritself; this also keepsRetryAttemptfrom being self-referential throughLLMError.attempts. FallbackAttempt now extends RetryAttempt instead of declaring its own index and error fields, so FallbackExhaustedError.attempts keeps its existing shape unchanged, provider and model alongside the inherited index and error.Added isFallbackExhaustedError, a guard function for narrowing a caught error to FallbackExhaustedError without a manual instanceof check.
-
53ec728: Added manual circuit-breaker control:
VernLLM.openCircuit(target?)andVernLLM.closeCircuit(target?)let a caller force a target's breaker open or closed (e.g. to pull a provider out of rotation ahead of known maintenance, or to skip the cooldown once a provider is confirmed healthy again), without waiting for real traffic to trip it. Both take an optional{ index?, model? }, defaulting to the primary target's shared circuit.getCircuitStatenow takes the same{ index?, model? }shape instead of a baremodelstring, so it can address fallback targets too, not just the primary. This is a breaking change to an already-shipped signature:llm.getCircuitState('gpt-4o')becomesllm.getCircuitState({ model: 'gpt-4o' }). Being accepted on minor sincevern-llmis still in beta andgetCircuitStatewas itself a fairly recent addition.An out-of-range
indexongetCircuitState/openCircuit/closeCircuitnow throwsRangeError, so it stays distinguishable from a real target that simply has no breaker configured (which still returns/no-ops normally). Passingmodelto a target whose breaker doesn't havecircuitBreaker.isolateByModelon now logs a warning instead of silently doing nothing, since the shared-bucket state or action is used regardless.When
modelis omitted ongetCircuitState/openCircuit/closeCircuit/getCircuitStates, it now defaults to that target's own configured model instead of the unlabeled bucket, matching the bucket real call failures/successes are recorded under for a target withisolateByModelon. An explicitmodelargument is unaffected.getCircuitStates()entries now includeisolateByModel, so a caller sweepingmodelacross a fallback chain with mixed per-target configs can tell which entries actually honored it.
Patch Changes
- af02955: The internal logger is now wrapped so a throwing custom logger can no longer break the call it was logging about. If a user-supplied logger.debug, logger.warn, or logger.error throws, the error is caught and dropped, and the original operation still completes normally.
- b56c35b: Cache adapter failures now go through logger.warn instead of being invisible. A get failure is treated as a miss and falls through to a real provider call. A set failure happens after the result is already computed and is still swallowed, just now logged. A delete failure used to propagate as a thrown error; it is now caught and logged instead.
2.2.0
Minor Changes
-
d1f7d17: Added a
call()overload fortoolChoice: 'none', narrowing the return type toContentResult<T>instead of the fullCallWithToolsResult<T>union, since the model is structurally barred from returning atool_callsresult in that case. This can change the inferred return type at existing call sites that already passtoolswithtoolChoice: 'none': code that defensively checkedisToolCallResult(result)or accessedresult.toolCallsthere will now see a type error, sincetoolCallsisn't a field onContentResult<T>.The
ContentResult<T>guarantee is now also enforced at runtime: if a provider (or a custom adapter) returnstool_callsanyway despitetoolChoice: 'none',call()throwsLLMError('api')instead of silently returning a{ type: 'tool_calls', ... }result that would contradict the narrowed type.Also tightened
cachedCall:reserveUsage/refundUsageno longer type-check inside the nestedcallobject onCachedCallParams/CachedToolCallParams/CachedStreamCallParams/CachedStreamToolCallParams, they belong at the top level alongsidecacheKey/ttl. A caller that bypasses the type system and sets them insidecallanyway now getsLLMError('validation')instead of a runtime warning and silent no-op.
Patch Changes
-
ad44612: Removed GitHub model and klusterAI from openAI compatible aliases and tests since they are deprecated.
-
49b7fbf: Fixed two bugs found during a code review pass:
FallbackExhaustedErrornow inheritsretryAfterMsfrom the last failed target's error, matching thetype/status/causeit already inherited. PreviouslyretryAfterMswas hardcoded toundefined, so a caller following the documented pattern of readingerr.retryAfterMson an'api'-typed error would silently lose the provider's actual Retry-After value in the one case where every target, including the last, failed with a rate limit.RateLimiter's internalTokenBucketno longer loses already-refilled capacity when the system clock moves backward (NTP correction, VM migration, etc.). A negative elapsed time between refills was previously multiplied straight into the bucket'savailablecount, silently discarding real capacity and rate-limiting harder than configured until the bucket caught back up. Elapsed time below zero is now treated as no time having passed, rather than negative time.Also removed a dead-code
maxQueueSizecheck inRateLimiter.acquire's fast path (the queue is always empty there, so the check could never fire); no behavior change.
2.1.1
Patch Changes
-
09c330b: Added
fromOpenAI, an alias offromOpenAICompatiblefor OpenAI clients. The existingfromOpenAICompatibleadapter supports providers such as Groq and Mistral.Passing a raw
new OpenAI(...)instance directly asclientstructurally satisfiesLLMClientfor basic non-streaming, text-only calls, but silently misses two things that only live in the adapter layer:ContentBlock[]multimodal translation to OpenAI'simage_urlformat, andcreateStreamwiring forstream: true(the raw SDK has nocreateStreammethod). NeweropenaiSDK majors (v7+) also widenedChatCompletionContentPartin ways that can make an unwrapped client fail to typecheck againstLLMClientaltogether, independent of any VernLLM version, sinceopenaiis not a peer dependency here.fromOpenAI(client)is a plain alias forfromOpenAICompatible(client), no behavior change beyond the name. Existing code passing a raw client is unaffected;fromOpenAIis the recommended path going forward. See Migration Notes for details.
2.1.0
Minor Changes
-
8014818: Added observability events, provider labeling, and fixed a tool-contract retry defect.
VernLLMOptions.onEventreports'retry'and'circuit_state'events as they happen. Fire-and-forget, mirroring the existingonUsagepattern exactly: a throwing handler is caught and logged, its return value is never read, and it can only ever change what gets reported, never what the call does.VernLLMOptions.name(default'primary') labels aVernLLMinstance. It's threaded intoTokenUsage.providerand every emitted event, so a sharedonEvent/onUsagehandler can tell multiple instances apart. This also lays the groundwork for multi-target fallback in a future release.CircuitBreakerwas refactored so every state mutation routes through a singletransition()method, guaranteeingonStateChange(and nowonEvent's'circuit_state') fires exactly once per real transition, never on a no-op like open to open.assertClosed/recordSuccess/recordFailurenow accept an optionalmodelparam, reported on the transition it triggers; the breaker's failure counting is unchanged, still shared-fate across models by default, so this only affects what's reported, not when the circuit opens or closes.Both the
'retry'/'circuit_state'events and the breaker'sassertClosedgate now use the model actually resolved for that call (honoring a per-callmodeloverride) instead of always the instance default.Fixed
validateToolCallArguments: an unknown tool name or a duplicate tool-call id was previously classifiedtype: 'api'with no distinguishing code, which meantshouldRetrytreated it as retryable, burning the whole retry budget on a request that was guaranteed to fail identically every time (the wire request doesn't change between attempts). These failures now carrycode: 'unknown_tool'orcode: 'duplicate_tool_call_id'(stilltype: 'api', so no existing type check breaks) and are excluded from retry. Every such issue in a response is now aggregated into one error'stoolIssues: ToolIssue[], instead of throwing on the first and hiding the rest. Schema-validation failures are unchanged: still a separate pass, stilltype: 'validation', still first-failure-only.LLMErrorgains two new optional, additive fields:code: LLMErrorCodeandtoolIssues?: ToolIssue[]. Both are appended as the last positional constructor params, so every existingnew LLMError(...)call site keeps compiling unchanged. -
11c83db: Added
VernLLMOptions.redact, applied to model output before it reaches the debug logger.debug: truelogs up to 800 characters of raw model output on success, and the provider's original error on failure, including a stream-open failure. Until now there was no way to scrub that output before it hit the logger, an accidental spot for prompt content or PII to end up in logs.redactcloses that gap:const llm = new VernLLM({ client: openai, model: 'gpt-4o', debug: true, redact: (text) => text.replace(/\b\d{3}-\d{2}-\d{4}\b/g, '[REDACTED]'), });Applied to every internal debug log line, the success output, a failed
call(), and a failed stream open, the one place an app has no other way to intercept, since these are directlogger.debugcalls rather than something routed through a callback.onEventpayloads,LLMError.cause, andonUsageFailurealready hand raw content straight to app-owned callbacks, so redacting those needs no help fromVernLLM; only the debug log required a new option.redactruns before every internallogger.debug()call regardless of whether that call ends up emitting anything. With the default console logger,debug: false(the default) means nothing is logged, soredacthas no visible effect. With a customlogger,VernLLMdoesn't checkdebugat all before calling into it, that logger's owndebug()decides whether to emit, soredactruns and can have a visible effect even withoutdebug: true.Additive and optional. Omitting
redactis a no-op, identical to today's behavior. -
8014818: Added client-side rate limiting.
VernLLMOptions.rateLimitqueues calls locally to stay under configuredrequestsPerMinute,tokensPerMinute, and/ormaxConcurrentcaps, instead of dispatching and letting the provider reject with a 429. This is proactive, unlike the existingRetry-Afterhandling in the retry loop, which only reacts after a self-inflicted rate limit has already cost a round trip. OmitrateLimitfor unlimited, exactly matching pre-existing behavior.new VernLLM({ client, model: 'gpt-4o', rateLimit: { requestsPerMinute: 500, maxConcurrent: 20 }, });Capacity is acquired per retry attempt, not once per call, since every retry is a real request against the same limits. For
stream: true, capacity is held for the connection's full lifetime and released only once the stream completes (success or a mid-stream failure), not when it merely opens, since a stream holds a real connection the whole time it's open.tokensPerMinuteis enforced against a pre-flight estimate (a chars/4 heuristic over message content plusmax_tokensby default, overridable viaestimateTokens), then reconciled against real reported usage once the call completes, so a systematically over- or under-estimating heuristic self-corrects rather than compounding.A call that can't get capacity within
maxQueueMs(default 30000, pass0to wait indefinitely) or finds the queue already atmaxQueueSize(default0, unbounded) throwsLLMErrorwithtype: 'quota_exceeded'and the newcode: 'local_rate_limit', reusing the existing type since a locally-stopped call before anything was sent is exactly whatquota_exceededalready means.shouldRetrynow excludes this code: the wait already happened, so retrying immediately would only requeue behind the same limit with nothing changed.Provider 429s are unaffected in shape, still
type: 'api',status: 429, but now also carry the newcode: 'provider_rate_limited'for callers that want to distinguish a real provider rate limit from a local one without checkingstatusdirectly.Queued waiters are served strictly FIFO, so a large call can't be starved indefinitely by a stream of smaller ones queued behind it, and a waiter whose
signalaborts while queued is removed and rejects withtype: 'aborted'immediately rather than continuing to hold a queue slot.onEventgains a'rate_limited'event, reported whenever an attempt actually had to wait for capacity, carryingwaitedMsand which bucket ('concurrency' | 'rpm' | 'tpm') was blocking it.New exports:
RateLimiter,RateLimitOptions,RateLimitReason,RateLimitAcquireResult,WireRequest, anddefaultEstimateTokens.LLMErrorCodegains'local_rate_limit'and'provider_rate_limited', andVernLLMEventgains the'rate_limited'kind, both additive to fields that were also newly introduced in this same release cycle, so nothing published to date is affected. Note for anyone consuming this as a standalone follow-on to an already-releasedonEvent/code: TypeScript still treats adding a member to a previously-public union as a compile-time break for consumers who exhaustivelyswitchoverLLMErrorCodeorVernLLMEvent['kind']with adefault: neverguard (the same tradeoff already accepted forcodeitself and forVernLLMEvent, deliberately not extended toLLMErrorType, which stays closed for this reason). -
62541be: Added cross-provider fallback, declared inline on the constructor.
VernLLMOptions.fallbacktakes an orderedFallbackTarget | FallbackTarget[], each with its ownclient/modeland, optionally, its ownmaxRetries,timeoutMs,chunkIdleTimeoutMs,baseDelayMs,defaultMaxTokens,defaultTemperature,nonRetryableStatus,circuitBreaker, andrateLimit(per-target overrides fall back to the parent instance's own option when omitted;circuitBreaker/rateLimitare never inherited, each target's is independent of every other target's). Order is the policy:VernLLMnever reorders, scores, or health-checks targets, it only walks the list as given, after the primary and after each earlier fallback target is exhausted or abandoned.const llm = new VernLLM({ client: openai, model: 'gpt-4o', fallback: [ { client: anthropic, model: 'claude-sonnet-5', name: 'anthropic' }, { client: gemini, model: 'gemini-2.5-flash', name: 'gemini' }, ], });VernLLMOptions.fallbackOndecides what happens once a target's own retries are exhausted or abandoned early:'next'moves on to the following target,'stop'gives up immediately.'retry'isn't a valid return here, retrying already happened inside the target. Defaults to the new exporteddefaultFallbackOn, which stops onparse/validation/aborted/quota_exceedederrors and on tool-contract failures (code: 'unknown_tool'/'duplicate_tool_call_id', the model ignoring the request rather than the provider being unhealthy) since none of those are fixed by trying a different provider, and moves on for everything else, including a rate-limited or open-circuit target. Exported so a caller can wrap rather than replace it.Every target keeps its own retry state, circuit breaker, and rate limiter, so tripping one target's breaker never affects another's. A
circuitBreaker-open primary now falls over to the next target instead of hard-failing the call, since an open breaker is just another target failure as far asfallbackOnis concerned.CallParams.meta, an optional{ current?: CallMeta }out-parameter, is written with{ provider, model, fallbackIndex, usedFallback, attempts }oncecall()resolves, so a caller who wants provider identity on the same line as the result doesn't need to read it back out ofonUsage. Ignored forstream: true, sincecall()returns before the outcome (and so the target that answered) is known;TokenUsage.provider/usedFallbackfromonUsagecover that case instead.TokenUsagegainsusedFallback?: booleanalongside the existingprovider?: string.onEventgains a'fallback'event, reported when the chain moves to the next target, carryingfrom/toprovider names,fromIndex/toIndex(-1for the primary), the normalized error that caused the move, andelapsedMsspent on the abandoned target.When every target fails,
call()throws the newFallbackExhaustedError(extendsLLMError, soisLLMError/instanceof LLMErrorstill passes, inheriting the last failure'stype), carryingattempts: FallbackAttempt[], every target's own normalized error in order, so a cross-provider outage stays debuggable without reproducing it. A lone target (nofallbackconfigured) throws exactly what it throws today, unchanged: the single-iteration path is identical to pre-fallback behavior.Fallback applies to stream-open failures only. Once a chunk has been emitted,
VernLLMdoes not fall over mid-stream, since splicing a second model's output into a response the consumer has already partially rendered would corrupt it; a stream-open failure (before the first chunk) falls over exactly like a non-streaming failure does.cachedCallcomposes with fallback automatically, since fallback lives insidecall(): the whole chain caches under one key and the successful result, however far down the chain it came from, is what gets stored, with in-flight coalescing covering the full chain too.As a small additive circuit-breaker improvement,
VernLLM.getCircuitStates(model?)now exposes the current circuit state for the primary and all fallback targets in declaration order. The existingVernLLM.getCircuitState(model?)continues to expose the primary circuit state.circuitBreaker.onStateChangeand thecircuit_stateonEventevent can be used to observe transitions, including those belonging to fallback targets.LLMErrorCodegains'fallback_exhausted', additive.New exports:
FallbackTarget,FallbackOn,FallbackAttempt,CallMeta,FallbackExhaustedError,defaultFallbackOn.Tests added covering: primary success leaving every fallback target untouched, falling over on primary exhaustion with a byte-identical wire request (including
tools) reaching the next target,parse/validation/quota_exceeded/tool-contract errors stopping the chain instead of falling over, a rate-limited or circuit-open target falling over, per-target breaker independence,FallbackExhaustedError.attemptscarrying every failure in order, the no-fallback-configured case throwing identically to pre-fallback behavior, the stream-open-only limitation,cachedCallstoring the fallback-produced result,TokenUsageidentity matching whichever target answered, the default and a customfallbackOnpolicy, the'fallback'event, and the additive circuit state API. Also added real-SDK integration tests driving actualopenai,@anthropic-ai/sdk,@google/genai, and@aws-sdk/client-bedrock-runtimeclients, each as a distinct fallback target against its own local mock server, exercising a full four-provider fallback chain, a real streaming open-failure fallover, andFallbackExhaustedErrorcollecting every real provider's parsed error. -
6924a7f: Added opt-in support for combining
toolswithjsonSchemaon Anthropic and Bedrock, on models that support native, schema-constrained output.Previously,
VernLLMunconditionally rejectedtoolscombined withjsonSchemaorschemaat the orchestration layer for every provider. The underlying provider-level collision that motivated that generic guard was specific to Anthropic/Bedrock, wherejsonSchemawas implemented internally as a forced single tool call sharing the sametools/toolConfigfield as caller-supplied tools. Both providers have since added a schema-constrained output mechanism that lives in its own request field, independent of tool calling, Anthropic'soutput_config.formatand Bedrock Converse'soutputConfig.textFormat, so the combination is no longer categorically invalid there, only invalid on models that lack that mechanism. The removed guard therefore covered bothjsonSchemaandschema;schemaitself remains client-side validation, but it was included in the old blanket mutual-exclusion check.fromAnthropicandfromBedrocknow acceptnativeStructuredOutputModelsas part of their secondoptionsargument, either a static list of model IDs or a predicate function. On a covered model,jsonSchemacomposes with realtoolsin the same request. There is no built-in default list: which models support this is each provider's call to make, not this package's, and it changes over time, so hardcoding a guess would risk silently routing a request onto a field a given model doesn't actually support, trading a clear validation error for a confusing one from the provider. Left unset (the default), every model keeps using the forced-single-tool-call emulation, andtools+jsonSchematogether is still rejected on Anthropic/Bedrock, exactly the pre-existing behavior. On Gemini and OpenAI-compatible clients, the underlying provider APIs can represent structured output and tools independently, but before this PR the sharedVernLLMguard prevented the combination from reaching those adapters; this PR removes that unnecessary orchestration-level restriction.schemaremains client-side validation and is no longer part of the generictoolsexclusion.Each provider's native mechanism has a narrower field set than the legacy forced-tool-call path, matched exactly to what each provider's real API accepts, verified against the real
@anthropic-ai/sdkand@aws-sdk/client-bedrock-runtimeclients rather than just asserted internally. Anthropic'soutput_config.formatsends onlytypeandschema, noname/description/strict. Bedrock'soutputConfig.textFormatnests the schema one level deeper than every other schema shape these adapters build, understructure.jsonSchema, and requiresschemathere as a JSON-encoded string, not the parsed object used everywhere else in the adapter;name/descriptionare accepted there, but notstrict.The generic
VernLLM.call()-level guard that used to rejecttools+jsonSchema/schemaunconditionally has been removed; the check is now left to each adapter, which has the model-specific capability information the orchestration layer doesn't. As a side effect, this also fixes Gemini: itsresponseSchemaandtoolsare independent fields, so the adapter can now send both, and OpenAI-compatible clients already pass both fields through independently. The actual compatibility change is therefore that the shared orchestration layer no longer blocks combinations that individual adapters/providers can support, while Anthropic/Bedrock retain validation on models without native structured-output support.Also fixed a bug, on Anthropic and Bedrock, where
toolswas silently dropped from the request wheneverresponse_format: 'json_object'was also set, reachable viajsonMode: trueorschema(withoutjsonSchema) alongsidetools. The JSON-instruction branch and the tool-building branch were structured as a singleif/else ifchain, so setting both meant only the JSON instruction was applied andtoolsnever reached the wire request, even though nothing about ajson_objectprompt instruction actually conflicts withtools/toolConfig. The two are now built independently.AnthropicAdapterOptions,BedrockAdapterOptions, andModelCapabilityOverrideare now exported from the package root (previouslyBedrockAdapterOptionswas never exported either, a pre-existing gap this also closes), sonativeStructuredOutputModelsandtoolUseSupportedModelscan be typed and referenced directly instead of relying on structural inference at thefromAnthropic/fromBedrockcall site.Docs updated:
core/tool-calling.mdxandcore/structured-output.mdxno longer describetools/jsonSchemaas unconditionally mutually exclusive, and gained a "Combining with tools" section coveringnativeStructuredOutputModelsand each provider's exact native wire shape;adapters/anthropic.mdxandadapters/bedrock.mdxgained matching per-provider sections with usage examples and shape callouts;API-reference/call-params.mdxandAPI-reference/notes.mdxcorrected to describe the new conditional (model-dependent) behavior instead of an absolute rule.Tests added covering:
jsonSchema+toolstogether on a covered model (with and without real tools present), the exact native wire shape for both providers, the validation error naming the model on an uncovered model, the predicate form ofnativeStructuredOutputModels, the legacy forced-tool-call path on an uncovered model (regression),toolsalone on a covered model (regression), thetoolUseSupportedModelspreflight also firing on the native path when realtoolsare sent alongsideoutputConfigon Bedrock, and thejson_object+toolsbug fix on both adapters (regression). Also added real-SDK integration tests, driving an actual@anthropic-ai/sdkclient and an actual@aws-sdk/client-bedrock-runtimeclient against a local mock server and asserting on the real wire body, for both providers' native path with real tools alongside it: these caught the wire-shape mismatches described above before release, which unit tests against hand-rolled fakes could not have caught on their own.
Patch Changes
-
68c620f: Reorganized the package's internal file structure. No public API changes.
VernLLMpreviously held request building, retry, the circuit breaker, the rate limiter, and cache orchestration all inline in one class. It now delegates toCallExecutor(request building, retry, breaker, limiter) andCacheOrchestrator(cache reads/writes and in-flight coalescing), leavingVernLLMitself as constructor wiring plus the publiccall/cachedCall/deleteCache/getCircuitStatesurface.src/internal/is now grouped by the subsystem that owns each file:internal/execution/for everythingCallExecutorneeds (callExecutor.ts,requestBuilder.ts,streamAccumulator.ts,retry.utils.ts,errors.utils.ts,wire.utils.ts,parse.utils.ts),internal/cache/for everythingCacheOrchestratorneeds (cacheOrchestrator.ts,cache.utils.ts,replay.utils.ts), andinternal/circuitBreaker.utils.ts/internal/usage.utils.tsstaying loose sinceVernLLMuses them directly.sse.ts,imageFormat.ts, andnativeStructuredOutput.tsmoved undersrc/adapters/internal/, since the provider adapters are their primary consumers.sse.tskeeps its package-root exports,parseSseStreamandSSE_PING, re-exported fromsrc/index.ts.The streaming accumulator (chunk buffering, backlog eviction, live delivery to a waiting consumer) is now its own module,
streamAccumulator.ts, takingonStreamSuccess/onStreamFailure/finalizecallbacks instead of reaching back intoCallExecutor's breaker and usage reporting directly.Tests moved to mirror the new source layout, one test file's path following its source file's path.
tests/unit/vernLLM.utils.unit.test.tswas split intoretry.utils.unit.test.ts,errors.utils.unit.test.ts, andusage.utils.unit.test.ts, matching the source split. A newstreamAccumulator.unit.test.tsexercises the accumulator directly against a hand-built chunk iterator, instead of only reaching it through a fullVernLLMinstance and a mock client.
2.0.0
Major Changes
-
c9c7414: Collapsed
cachedCall/cachedLLMCallinto a single publiccachedCall.Previously
VernLLMexposed two caching methods: a genericcachedCall({ cacheKey, ttl, fn })that cached whateverfnreturned with no retry/timeout/circuit-breaker guarantees, andcachedLLMCall({ cacheKey, ttl, call })that composedcall()(retry/timeout/circuit-breaker) with caching. This split didn't match vern-llm's "production-ready resilience for LLM calls" scope, and the generic form was really a general-purpose memoizer that happened to live on the LLM client.cachedLLMCallis renamed tocachedCall. The publiccachedCall()now always composescall()internally, so cached results get the same retry/timeout/circuit-breaker behavior as any other LLM call. There is no longer a public way to cache an arbitrary non-LLM function throughVernLLM. If you were using the old fn-basedcachedCall({ fn })for general-purpose caching or coalescing unrelated to an LLM call, switch to a dedicated caching library (e.g.async-cache-dedupe) at the application level instead.Type renames to match:
CachedLLMCallParams<T>→CachedCallParams<T>(now the public type forcachedCall()without tools).CachedLLMToolCallParams<T>→CachedToolCallParams<T>(public type forcachedCall()with tools).- The old generic
CachedCallParams<T>(thefn-based shape) is no longer exported from the package.
See the Migration Notes for details.
-
7cdfb6b: Added first-class tool calling support.
call()now acceptstools, an array ofToolDefinitions the model may request, and an optionaltoolChoiceto control whether and which tool is used. Whentoolsis set,call()returns aCallWithToolsResult<T>discriminated union ({ type: 'content', content }or{ type: 'tool_calls', toolCalls, content? }) instead ofTdirectly. VernLLM never executes tools itself, applications run them and continue the conversation by appending an assistanttoolCallsturn and a matchingtoolturn tohistory.fromAnthropic,fromBedrock,fromGemini, and the OpenAI-compatible adapters all translatetools/toolChoice/tool_callsinto that provider's native tool-calling mechanism.fromFetchsupports tool calling too:mapResponsecan return atoolCallsarray alongsidecontent.cachedLLMCall()supports tool-enabled calls the same way it supports plain ones.This is a major release because of two breaking type changes:
ConversationTurnis now a discriminated union instead of one flat{ role, content }shape, adding atoolcase and makingcontentoptional onassistantturns. Constructing turns is unaffected; code that readsturn.contenton anassistantturn assuming it's always astring, or that used an exhaustiveswitch/assertNeverpattern overrole, will need updating.LLMClient.messageswidened to include tool turns andtool_callson assistant messages. This only affects hand-writtenLLMClientimplementations that bypass the built-in adapters. Any such adapter that declares or processes the message type, not only ones with an exhaustive role switch, needs to update its types and handle tool messages and assistanttool_callscorrectly.
See the Tool Calling docs and Migration Notes for details.
Minor Changes
-
94203cf: Improve provider SDK compatibility and adapter support across Anthropic, Gemini, OpenAI-compatible, and Bedrock providers. Adds stronger schema validation, streaming and cancellation handling, improved error handling, and expanded real-SDK integration coverage.
-
64839b2: Fixed several gaps in streaming (
stream: true).Added a
chunkIdleTimeoutMsoption (default 30000ms,0disables it) that bounds the gap between chunks after the first. Previously only stream-open and the first chunk were bounded bytimeoutMs, so a connection that streamed one chunk then hung would never fail. An idle-timeout failure now also trips the circuit breaker, unlike other mid-stream failures, since a provider that reliably streams one chunk then hangs would otherwise never trip it.Added
complete?: booleantoStreamChunk/WireStreamChunk'stool_call_deltavariant. Gemini always delivers afunctionCall's arguments whole in one chunk, indistinguishable before now from a genuine fragment from other providers. Gemini's adapter and cache-replay chunks (buildReplayChunks) now set it.Chunk buffer eviction is now logged at debug level when a caller doesn't read
chunks(or falls far behind), so missing chunks in a reconstructed stream can be traced back to eviction instead of looking like a transport bug.Added a
pingvariant toWireStreamChunkfor provider keep-alive signals with no content.fromFetch(SSE comment-line pings, via a new exportedSSE_PINGsentinel) andfromAnthropic(Anthropic's documentedpingevents) both recognize these and reset the idle timer, instead of silently dropping them and risking a timeout on an actively alive long-running stream.This is purely additive. Existing callers, and adapters that don't emit
pingor setcomplete, are unaffected. -
bc1fc46: Added
onUsageFailure, an opt-in hook that reports token usage for calls that spent real tokens but then failed on VernLLM's own post-response handling, such as parse or schema validation errors, instead of silently dropping that spend.onUsageonly fires on full success, so there was previously no way to know a failed call had still cost tokens.onUsageFailurefills that gap: it fires once per failed attempt when the provider response included usage data, receiving the sameTokenUsageshape asonUsageplus theLLMErrorthat caused the failure. It covers any error thrown after a response arrives, not just parse/validation, and is skipped for transport failures (timeout, network error, non-retryable status) and for calls that were aborted, since in both cases there is no usage to report or the error type would not match whatcall()ultimately throws.This is purely additive.
onUsage's existing contract is unchanged, and no action is needed for existing integrations.See the Usage Tracking docs for the full shape and firing semantics.
-
56aeab6: Added streaming support to the generic
fromFetchadapter.Three new optional
FetchAdapterConfigfields wire up streaming for any OpenAI-compatible-shaped HTTP endpoint:requestStreamopens the streaming HTTP request (defaults to nativefetch),parseStreamFramessplits the raw response bytes into individual event payloads (defaults to Server-Sent Events framing), andmapStreamEventmaps one parsed event into zero, one, or moreWireStreamChunks.mapStreamEventis required forstream: truecalls; a config that omits it now throws a clearLLMError('validation')instead of failing silently or confusingly mid-stream.Also fixed a related correctness bug:
fromOpenAICompatibleand its aliases previously sentstream_options: { include_usage: true }unconditionally on every streamed call. Not every OpenAI-compatible provider supports that field, so a provider that rejects unrecognized parameters could fail every streamed call outright, which is what Mistral used to do.stream_optionsis now gated behind a newsupportsStreamUsageadapter option, defaulting totrue. This default was verified directly against provider docs for Groq, DeepSeek, Mistral, Perplexity, and LM Studio, all of which support the field, so existing callers keep getting usage in their stream exactly as before. Pass{ supportsStreamUsage: false }only for a provider you've confirmed rejects it.This is purely additive. Existing
fromFetchconfigs without the new streaming fields, and existing OpenAI-compatible aliases, are unaffected. -
c536167: Added a way to opt out of VernLLM's
temperature: 0.2default and let the provider apply its own default instead.Pass
temperature: nullon a call, ordefaultTemperature: nullon theVernLLMinstance, andtemperatureis omitted from the request entirely rather than sent as0.2. A per-calltemperaturestill wins over the instance-leveldefaultTemperature, which still wins over the0.2fallback, same resolution order asmaxTokens/defaultMaxTokens.This is purely additive for normal
call()usage. Omittingtemperatureeverywhere keeps sending0.2exactly as before, no behavior changes for existing callers.One narrow caveat: making this work required widening
LLMClient's wire-leveltemperature: numbertotemperature?: number. This only affects hand-writtenLLMClientimplementations that assumeparams.temperatureis always anumberwithout checking whether it'sundefined, every built-in adapter (fromAnthropic,fromBedrock,fromGemini,fromFetch, OpenAI-compatible) is unaffected. See Migration Notes for details. -
9fc3ee6: Added tool calling support to the generic
fromFetchadapter.mapResponsecan now return an optionaltoolCallsarray alongsidecontent:{ id, name, arguments }per call, withargumentsalready JSON-encoded as a string, the same wire format every other adapter produces.fromFetchtranslates these intoWireToolCalls socall()surfaces them throughCallWithToolsResultexactly like the built-in provider adapters.mapRequestalready received the full request (includingtools/toolChoice) before this change, so only the response side needed a new seam.contentis now optional onmapResponse's return type too, since a pure tool-call turn may have no text. An emptytoolCallsarray is treated identically to an omitted one, no special-casing needed either way.This is purely additive. Existing
fromFetchconfigs that never settoolsand return only{ content, usage? }frommapResponseare unaffected.See the Custom Providers docs for a full example.
1.7.1
Patch Changes
- 2eff4ac: Removed openai as a peer dependency
1.7.0
Minor Changes
-
837d9d1: Added an opt-in preflight check for Bedrock tool use support.
fromBedrocknow accepts a secondoptionsargument withtoolUseSupportedModels, either a static list of model IDs or a predicate function. When set, ajsonSchemacall to a model not covered by it fails fast withLLMError('validation')before the request is sent.Left unset, the default, no preflight check runs and a
jsonSchemacall to an unsupported model still surfaces Bedrock's raw error unchanged.fromBedrockdoes not try to reclassify or guess at that error from its text, since AWS's error message for an unsupported model is not a documented, stable contract.Also refactored
VernLLM.tsinternally: inlined several small constructor only helper methods, tightened redundant logic, and expanded JSDoc coverage across the public API (constructor,call,cachedCall,cachedLLMCall,deleteCache,getCircuitState) with clearer parameter and return descriptions. No public behavior changed.Docs updated in
adapters/bedrock.mdxwith a new "Preflighting tool use support" section.Tests added covering the allowlist and predicate forms of
toolUseSupportedModels, confirmingconverseis never called on a rejected preflight, and confirming nonjsonSchemacalls and the no option default skip the check entirely. -
52d5f74: Added an extensible cache adapter framework that allows applications to customize and compose caching strategies.
Added
resolveKeysupport toCacheAdapterfor canonicalizing cache keys before lookups and in-flight request coalescing. Cache adapters can now transform equivalent but differently formatted keys into a shared canonical key, allowing requests such as normalized, semantic, or fuzzy matches to reuse the same cached response and active generation. This enables advanced cache matching strategies without changing VernLLM's core caching flow, while keeping existing adapters fully compatible through the optionalresolveKeymethod.Included built-in adapters:
InMemoryCacheAdapterfor zero-dependency local caching with TTL support and bounded memory usage.NormalizedCacheAdapterfor normalizing cache keys to avoid duplicate entries caused by formatting differences.TieredCacheAdapterfor multi-level caching with fast local L1 caches and shared L2 caches, including promotion of L2 hits back into L1.
This enables support for advanced caching architectures such as local + distributed caches, custom cache providers (Redis, Upstash, databases, etc.), and future semantic or fuzzy cache implementations without changing VernLLM's core execution flow.
Docs has been update within guides to showcase these new adapters. Tests has been added on
cachedCall.unit.test.tsandindex.exports.unit.test.ts
Patch Changes
-
426d48e: Internal refactor: extract
withReservedUsageandnormalizeErrorout ofVernLLMintointernal/vernLLM.utils.tsas standalone functions, with added unit test coverage. No public API or behavior changes. -
02e8df9: Fix cache key normalization, tiered cache key resolution, and structured output adapter metadata forwarding.
-
NormalizedCacheAdapter: punctuation is now replaced with a space instead of being removed outright. Previously"2+2"and"2 + 2"normalized differently ("22"vs"2 2") because removing punctuation collapsed adjacent characters. Both now normalize consistently to"2 2". -
TieredCacheAdapter: now implementsresolveKey, forwarding to L1's implementation if present, otherwise L2's, otherwise returning the original key unchanged. -
fromAnthropic:jsonSchemanow forwards schema metadata into Anthropic tool use. The adapter passesname,description,input_schema, andstrictinto the generated tool definition and continues using forced tool calls for structured output. -
fromBedrock:jsonSchemanow forwards schema metadata into Bedrock Converse tool use. The adapter passesname,description,inputSchema, andstrictinto the generated tool spec and forces tool selection throughtoolChoice. Strict enforcement depends on the selected Bedrock model's tool support. -
fromGemini:jsonSchemanow forwards schema descriptions into Gemini'sgenerationConfig.responseSchema. Structured output usesresponseMimeType: 'application/json'withresponseSchema; Gemini does not use a separatestrictflag. -
Removed references to the deprecated
@google/generative-aiSDK from Gemini adapter docs and comments. The adapter uses structural typing and is not SDK-specific.
Docs updated in:
core/caching.mdx,guides/caching-methods/normalized.mdx,guides/caching-methods/tiered.mdx,core/structured-output.mdx, andadapters/gemini.mdx.Tests added covering punctuation normalization,
TieredCacheAdapter.resolveKeyforwarding, and structured output adapter behavior. -
1.6.0
Minor Changes
-
09b75c0: Improve usage metering, cancellation handling, and reliability across call paths.
- Add abort signal support to cached call flows and usage metering hooks.
- Expose
{ coalesced, signal }to usage reservation and refund callbacks. - Ensure reservations are only refunded when successfully created, including cancelled requests.
- Centralize usage reservation and refund handling across
call(),cachedCall(), andcachedLLMCall(). - Fix circuit breaker accounting so validation, parsing, and caller cancellation failures do not count as provider failures.
-
babe641: Improve usage metering lifecycle handling across request paths.
- Add usage reservation and refund support to cached call flows without duplicating logic.
- Add abort-aware usage hooks with
{ coalesced, signal }context. - Refund successful reservations when requests are cancelled before execution begins.
- Improve reservation and refund failure handling without changing call error semantics.
- Prevent validation, parsing, and caller cancellation errors from being recorded as circuit breaker failures.
Patch Changes
-
bdee813: Fix
refundUsagebeing called even when the correspondingreserveUsagecall itself failed.Previously, if
reserveUsagethrew (e.g. quota already exhausted),refundUsagewould still fire for that caller, incorrectly refunding a reservation that was never actually made.refundUsageis now only invoked ifreserveUsagesucceeded, for both the triggering caller and coalesced callers incachedCall/cachedLLMCall.
1.5.0
Minor Changes
-
20591dc:
LLMErrornow preserves the original error thrown by the provider client, instead of discarding it once the status code has been extracted.Changes:
-
LLMError: added two new optional fields,causeandretryAfterMs.causecarries the raw value thrown by the underlying client (the actual SDK/HTTP error), so consumers can inspect the provider's real rejection reason (message, response body, etc) even though the top-levelLLMErrormessage stays a generic'LLM request failed'.retryAfterMscarries the parsedRetry-Aftervalue (if any) from the last failed attempt, using the same delta-seconds/HTTP-date parsing and cap already used internally for backoff. -
normalizeError: now attaches both fields when building the final thrownLLMErrorfor'api'and'unknown'error types. Existing consumers checking.type/.status/.issuesare unaffected. This is purely additive. -
debuglogging: previouslylogger.debugonly fired on a successful response. It now also fires on the failure path via a newdescribeError()helper, logging the provider's actual rejection reason (.erroror.message) before the normalizedLLMErroris thrown. This makesdebug: trueuseful for diagnosing failed calls, not just inspecting successful output. -
Tests: added coverage in
vernLLM.call.unit.test.tsfor.causebeing preserved on both'api'and'unknown'errors, and for.retryAfterMsbeing surfaced on the final thrown error. -
Docs: updated
core/error-handling.mdx(new.cause/.retryAfterMsfields, corrected the now-outdated callout claiming the raw error wasn't preserved, added a "Debugging a failed call" section) andcore/logging.mdx/API-reference/configuration.mdx(debugnow also covers the failure path; also fixed an unrelated pre-existing docs bug incorrectly statingdebugdefaults toNODE_ENV !== 'production'when the actual default isfalse).
-
Patch Changes
- 8dbd711: Throw
LLMError('validation')whenschemais combined withjsonMode: false(withoutjsonSchema), instead of silently skipping validation and returning an unvalidated string cast to the schema's type. - 4d0366f: update install section on readme
1.4.0
Minor Changes
-
95b1a36: Coalesce concurrent
cachedCallmisses for the samecacheKeyinto a singlefn()call.Previously, every concurrent request for the same
cacheKeythat missed the cache independently calledfn(), causing a cache stampede: N simultaneous callers could trigger N calls to the underlying (possibly expensive) LLM call before any of them had a chance to populate the cache.Now only the first caller (the "trigger") calls
fn(); every other concurrent caller for the same key waits on that same in-flight call and shares its result or failure.reserveUsage/refundUsagenow receive a{ coalesced: boolean }argument, so applications can decide how coalesced callers are billed: full price, a reduced rate, or not billed at all. This is backward compatible -> existing() => Promise<void>implementations don't need to change.Docs updated in
core/caching.mdxto describe the coalescing behavior and the newcoalescedflag. -
480e0c6: Honor a
Retry-Afterheader on retryable failures instead of always using exponential backoff.Changes:
-
Core: Added
extractRetryAfterMs()ininternal/vernLLM.utils.ts, which reads.headers(fetch-style) or.response.headers(axios-style) off a thrown error and parsesRetry-Afterin either delta-seconds ("30") or HTTP-date form.getBackoffDelay's previously-inline10_000default is now the sharedDEFAULT_MAX_DELAY_MSconstant, also used to cap the honoredRetry-Aftervalue so a misbehaving/adversarial header can't stall a caller indefinitely. -
recoverDelay: now usesextractRetryAfterMs(error) ?? getBackoffDelay(...), falling back to today's exponential-backoff-with-jitter exactly as before when no usable header is present. No adapter changes needed. Headers already flow through on thrown errors (fetch adapter via the priorrequest/headers PR, SDK-based adapters natively). -
Tests: added
tests/unit/vernLLM.utils.unit.test.tsforextractRetryAfterMs(delta-seconds, HTTP-date, axios vs Headers-like shapes, capping, past-date clamping, missing/unparseable header), plus end-to-end retry tests invernLLM.call.unit.test.ts(honors Retry-After over a larger configured backoff, caps an oversized Retry-After, falls back to backoff when absent).
-
-
eca6cf2: Improve the
fetch.tsadapter: allow an injectablerequestfunction (defaults to nativefetch) typed against aResponseLikeinterface for interop with axios/node-fetch/etc, skipbody/Content-Typefor GET/HEAD requests, and attachres.headersto thrown errors so downstream retry logic can readRetry-After.Minor bump: fully additive, no changes to existing
fromFetchcall signatures or behavior for POST/PUT/PATCH. -
06c5297: Add multimodal input support through
userContent.userContentnow accepts either a plain string or aContentBlock[]array containing text and image blocks. Existing string-based calls continue to work unchanged.Image blocks are translated automatically by provider adapters:
- OpenAI-compatible providers pass through native multimodal content.
- Anthropic converts image blocks to image source blocks.
- Gemini converts image blocks to inline data parts.
- AWS Bedrock converts image blocks to Converse image content blocks.
This enables sending images alongside text while keeping the existing text-only API backwards compatible.
Patch Changes
-
1ed6246: Fix circuit breaker allowing multiple concurrent trial calls during half-open.
assertClosed()transitioned the circuit tohalf-openonce the cooldown elapsed, but every concurrent caller after that point was also let through unblocked, since the guard only checked forstate === 'open'. This meant several "trial" calls could hit the provider at once right when the cooldown ended, instead of the intended single trial.Added a
trialInFlightflag: only the first caller during half-open becomes the trial and reaches the provider; every other concurrent caller is rejected immediately withcircuit_openuntil the trial's outcome is recorded viarecordSuccess/recordFailure.
1.3.0
Minor Changes
-
f1c238f: Fix default behaviors that didn't match the library's intended resilience/logging guarantees.
nonRetryableStatus: default extended from[400, 401, 403]to[400, 401, 403, 404, 422]. 404/422 can never succeed on retry, so retrying them was always wasted.- Debug logging: no longer defaults to on when
NODE_ENV !== 'production'. Now defaults tofalse, since debug logging can output raw response content and many environments never setNODE_ENVexplicitly. Opt in viadebug: true.- Unit tests: added for the debug logger
- Docs updated to match (
error-handling.mdx,logger.mdx).
Minor bump: changes default behavior for existing consumers, but explicit
debug/nonRetryableStatussettings are unaffected.
1.2.0
Minor Changes
-
690f7f3: Add named adapter aliases for additional OpenAI-compatible LLM providers.
Changes:
- Source: Added new provider aliases for additional OpenAI-compatible providers, all backed by
fromOpenAICompatiblewith zero request/response transformation. - Adapters: Added support for providers including xAI, NVIDIA NIM, Vercel AI Gateway, Cloudflare Workers AI, GitHub Models, Nebius, SambaNova, Baseten, DashScope, Featherless, Friendli, SiliconFlow, LiteLLM Proxy, Parasail, StepFun, MiniMax, Lambda Labs, Snowflake Cortex, Anyscale, Lepton, kluster.ai, Inference.net, Infermatic, AtlasCloud, and 01.AI.
- Docs: Expanded
openai-compatibledocumentation with the full list of supported named adapters and clarifiedfromOpenAICompatible()as the generic fallback. - Homepage: Updated the provider list with the newly supported providers.
- Changeset: Minor bump for
vern-llm: purely additive, no breaking changes.
- Source: Added new provider aliases for additional OpenAI-compatible providers, all backed by
1.1.0
Minor Changes
-
b46d6f7: Added named adapter aliases for 9 more OpenAI-compatible LLM providers to
vern-llm: OpenRouter, Perplexity, DeepInfra, Novita, Hyperbolic, Moonshot, Zhipu, LM Studio, and vLLM.Changes:
- Source:
fromOpenRouter,fromPerplexity,fromDeepInfra,fromNovita,fromHyperbolic,fromMoonshot,fromZhipu,fromLMStudio,fromVLLMadded as aliases forfromOpenAICompatibleinpackages/vern-llm/src/adapters/openaiCompatible.ts, re-exported viaadapters/index.tsandsrc/index.ts - Tests: added to the parameterized alias check in
openaiCompatible.unit.test.ts(18/18 passing) - Docs:
adapters/index.mdxandadapters/openai-compatible.mdxupdated to list all providers as named wrappers - Homepage:
home.utils.tsprovidersarray expanded with icons + doc links for all new providers (fixedVllmcasing to match actual@lobehub/iconsexport) - Changeset: minor bump for
vern-llm: purely additive, no breaking changes
Verified:
tsc --noEmitclean on both the package and docs app,dist/rebuilt viatsdownto include new exports,changeset statusconfirms minor bump. - Source:
1.0.0
Major Changes
-
96a29f4: Breaking:
CacheAdapter.get()now returnsPromise<{ hit: boolean; value: T | null }>instead ofPromise<T | null>.This lets
cachedCall/cachedLLMCallcorrectly distinguish a cache miss from a legitimately cachednullvalue, so a validnullresult is now reused from cache instead of being treated as a miss and re-triggering an LLM call.InMemoryCacheAdapter(the built-in default) is updated automatically. No action needed if you're using it. If you've implemented a customCacheAdapter(Redis, Upstash, or otherwise), you'll need to update itsget()method. See the migration guide below.Also in this release:
Make
CallParams.systemPromptoptional and omit system messages when unset. ExportAnthropicClient,GeminiClient, andBedrockConverseClientas public types. Add anadaptersbarrel export for provider adapters. Refactor internal types into focused modules. Add regression and integration test coverage for optional system prompts and adapter behavior. Add Anthropic adapter coverage to verify provider payloads omitsystemwhensystemPromptis not provided. Add cache adapter test coverage for custom adapter support, cache size bounds, and cache failure handling. Add in-memory cache size limiting to prevent unbounded growth. Bump the major version to reflect the breakingCacheAdapter.get()change.Migration guide
CacheAdapter.get()Before:
class MyCacheAdapter implements CacheAdapter<MyValue> { async get(key: string): Promise<MyValue | null> { const raw = await redis.get(key); return raw ? JSON.parse(raw) : null; } // ... }After:
class MyCacheAdapter implements CacheAdapter<MyValue> { async get(key: string): Promise<{ hit: boolean; value: MyValue | null }> { const raw = await redis.get(key); if (raw === null) { return { hit: false, value: null }; } return { hit: true, value: JSON.parse(raw) }; } // ... }The key change:
hitshould betruewhenever the key existed in the underlying store (even if the stored value itself isnull), andfalseonly when nothing was found. Most adapters can derive this directly from whatever "does this key exist" signal their underlying store already gives them (e.g. Redis returningnullvs. a real value, or anEXISTScheck).If you don't want to implement the distinction and are fine with
nullresults simply never being served from cache, you can also just return{ hit: value !== null, value }from your existingget()logic as a drop-in shim.
0.5.0
Minor Changes
- 037e8ee: Add delete cache functionality to vernLLM
Patch Changes
- e18b37e: add keywords to package
0.4.0
Minor Changes
- 5e029b2: Add support for multi-turn conversation history via the
historyoption inCallParams. Conversation history is now forwarded to all supported providers, including assistant messages, enabling native multi-turn interactions.
0.3.0
Minor Changes
- 761d860: Make LLM throw LLMerror(timeout) when timeout aborts request
Patch Changes
- afd54d9: Affirm directory on package
0.2.1
Patch Changes
- ee5bb90: Connect repo with package
0.2.0
Minor Changes
- dbce6e2: created a
tsconfig.base.jsonwhich thetsconfig.jsonextends from