Migration Notes
Breaking changes between versions, and what to update
This page tracks breaking changes across VernLLM releases. Most releases are additive, this
page only covers the ones that aren't.
2.3.0: LLMError taxonomy rework
type stays a small, closed set a caller can exhaustively switch over, while code carries the
specific reason underneath it. Several already-shipped type/code values move as part of this,
and the constructor shape changes. Skip to what applies to you: tool contract errors move to
validation, local rate limits get three specific
codes, invalid_credentials splits into two
codes, FallbackExhaustedError.type
is now fixed, the constructor takes
an options object, or toolIssues is
removed. Reading an already-normalized LLMError (the
switch (err.type) / isLLMError pattern) is unaffected by any of this; only specific values and
construction change.
2.3.0: Tool contract errors move to validation
unknown_tool, duplicate_tool_call_id, tool_choice_none_violated, and the previously uncoded
"provider returned tool_calls with no tools sent" case (now code: 'unexpected_tool_calls')
move from type: 'api' to type: 'validation'. They're a provider contract violation, not an
HTTP failure:
// Before
if (err.type === 'api' && err.code === 'unknown_tool') { ... }
// After
if (err.type === 'validation' && err.code === 'unknown_tool') { ... }A switch (err.type) with an exhaustiveness check no longer needs an 'api' case to cover tool
contract codes, but does need to route them through 'validation' instead if it branches on
code inside that case.
2.3.0: Local rate-limiting codes
Local rate-limit rejections move from type: 'quota_exceeded', code: 'local_rate_limit' to
type: 'rate_limited', split into three specific codes:
// Before
if (err.type === 'quota_exceeded' && err.code === 'local_rate_limit') { ... }
// After
if (
err.type === 'rate_limited' &&
(err.code === 'rate_limit_queue_full' ||
err.code === 'rate_limit_queue_timeout' ||
err.code === 'rate_limit_capacity_exceeded')
) { ... }type: 'quota_exceeded' now means only what it originally described: a reserveUsage hook
rejecting the call before a request was sent. See Rate Limiting for
what triggers each of the three new codes.
2.3.0: authentication/authorization replace invalid_credentials
// Before
if (err.code === 'invalid_credentials') { ... }
// After
if (err.code === 'authentication' || err.code === 'authorization') { ... }authentication is HTTP 401, authorization is HTTP 403, so a caller can now tell a missing key
apart from a key that lacks access. New codes not_found (404), payload_too_large (413),
server_error (5xx), and empty_response round out the HTTP-status-derived set; a new
type: 'network' with code: 'connection_failed' separates transport-level failures (DNS,
connection refused, connection reset) from the catch-all type: 'unknown'.
2.3.0: FallbackExhaustedError.type is always 'fallback_exhausted'
// Before: type inherited from whichever target failed last
err.type; // e.g. 'api', 'timeout' — varied per failure
// After: always its own identity
err.type; // 'fallback_exhausted'status and retryAfterMs still inherit from the last attempt. Read the last attempt's own
type via err.attempts.at(-1)!.error.type if you need it. See
FallbackExhaustedError for the full shape.
2.3.0: LLMError's constructor is now an options object
// Before
new LLMError(
'Rate limit queue timed out',
'rate_limited',
undefined,
undefined,
undefined,
undefined,
'rate_limit_queue_timeout',
);
// After
new LLMError('Rate limit queue timed out', 'rate_limited', { code: 'rate_limit_queue_timeout' });This only affects constructing an LLMError directly, not catching one. Two real audiences:
someone writing a custom LLMClient adapter who throws LLMError themselves the way VernLLM's
own adapters do internally, and a subclass calling super() positionally
(FallbackExhaustedError itself needed updating for this same change). Every field (status,
code, issues, cause, retryAfterMs, type) keeps the same name and meaning, only how they
get set changes.
2.3.0: toolIssues is removed, use issues
// Before
console.log(err.toolIssues);
// After
console.log(err.issues);issues is now the only place tool contract problems (a ToolIssue[]) or a schema validator's
error object are carried. issues also gained real types for several other invalid_params
codes previously uncoded; see issues for the full
LLMErrorIssuesByCode table and the new hasIssues type guard.
2.3.0: getCircuitState's model argument moved into a target object
getCircuitState used to take a bare model string directly:
// Before
llm.getCircuitState('gpt-4o');It now takes an optional target: { index?, model? }, matching the new openCircuit/
closeCircuit methods added in this release, so a model can be paired with index to address a
fallback target rather than only the primary:
// After
llm.getCircuitState({ model: 'gpt-4o' });Calling getCircuitState() with no arguments is unaffected either way, it still reads the primary
target's state. getCircuitStates(model?) also keeps its existing bare-string signature, this
change is scoped to getCircuitState only.
Being accepted on a minor rather than a major since vern-llm is still in beta and
getCircuitState was itself a fairly recent addition.
See Circuit Breaker for the
current reference, including openCircuit/closeCircuit and how an omitted model now resolves.
2.2.0: cachedCall's call no longer accepts reserveUsage/refundUsage
CachedCallInput (the top-level shape cachedCall() takes, alongside cacheKey/ttl) already
extends UsageHooks, the same interface CallParams<T> extends for plain call(). Since both
positions structurally accepted the same two fields, nothing stopped reserveUsage/refundUsage
from being placed inside the nested call object instead of at the top level, where cachedCall
actually reads them from. That mistake used to be caught only at runtime, with a warning, and the
hooks were silently ignored:
// Before: typechecked, but silently ignored with a runtime warning
await llm.cachedCall({
cacheKey: `cv:${cvId}`,
ttl: 3600,
call: {
systemPrompt,
userContent,
reserveUsage: (p) => quota.reserve(userId, p), // ignored
refundUsage: (p) => quota.refund(userId, p), // ignored
},
});call's type on CachedCallParams, CachedToolCallParams, CachedStreamCallParams, and
CachedStreamToolCallParams now omits reserveUsage/refundUsage entirely
(Omit<CallParams<T>, 'reserveUsage' | 'refundUsage'>), so this is a compile error instead:
// After: reserveUsage/refundUsage move to the top level
await llm.cachedCall({
cacheKey: `cv:${cvId}`,
ttl: 3600,
reserveUsage: (p) => quota.reserve(userId, p),
refundUsage: (p) => quota.refund(userId, p),
call: { systemPrompt, userContent },
});A caller that bypasses the type system entirely (plain JS with no type checking, or an explicit
cast past the narrower call type) can still construct the old, invalid shape at runtime. That
case now throws LLMError('validation') instead of logging a warning and silently continuing:
reserving usage twice for the same logical request, or silently skipping reservation altogether,
are both worth failing loudly over rather than depending on whether anyone was watching the logs.
See Caching and Usage Metering for the current reference.
2.1.1: fromOpenAI: wrap raw OpenAI clients
Passing client: new OpenAI(...) straight into VernLLM isn't a hard requirement, LLMClient is
shaped to structurally match chat.completions.create, so a bare SDK instance satisfies it for
basic non-streaming, text-only calls. But it silently skips two things that only live inside the
adapter layer:
- Multimodal translation.
ContentBlock[]userContent(VernLLM's provider-agnostic image format) is only converted to OpenAI's nativeimage_urlshape insidefromOpenAICompatible. A raw client gets VernLLM's internal shape passed straight through, not OpenAI's. - Streaming. The OpenAI SDK's
createreturns an async iterable when called withstream: truerather than exposing a separatecreateStreammethod, so a bare client has nocreateStreamat all.stream: trueagainst an unwrapped client throwsLLMError('validation').
Separately, newer openai SDK majors (v7+) widened ChatCompletionContentPart to include a file
variant, which can make new OpenAI(...) fail to typecheck against LLMClient even for plain
non-multimodal calls, depending on the exact openai version installed, independent of any VernLLM
version. Since openai is not a peer dependency, this can surface after bumping openai alone with
no corresponding VernLLM changelog entry to explain it.
fromOpenAI is a new named alias for fromOpenAICompatible, listed alongside the other named
providers:
// Before
const llm = new VernLLM({ client: new OpenAI({ apiKey: process.env.OPENAI_API_KEY }) });
// After
import { fromOpenAI } from 'vern-llm';
const llm = new VernLLM({ client: fromOpenAI(new OpenAI({ apiKey: process.env.OPENAI_API_KEY })) });This is additive, not breaking: existing code passing a raw client keeps working for the subset of
cases it already worked for (whatever openai version you have installed permits). fromOpenAI is
the recommended path going forward, matching every other named provider in
OpenAI-Compatible.
2.0.0 bundles three unrelated breaking changes. Skip to what applies to you: renamed the caching
method (cachedCall/cachedLLMCall), use tool calling
or read ConversationTurn directly (tool calling), or hand-roll a custom
LLMClient (covered inside the tool calling section, plus a small
temperature change). Plain call() usage with the
built-in adapters and no tools is unaffected by all three.
2.0.0: Collapsing cachedCall/cachedLLMCall
VernLLM used to expose two caching methods with overlapping names: a generic cachedCall({ cacheKey, ttl, fn }) that cached whatever fn returned, with no retry/timeout/circuit-breaker
guarantees, and cachedLLMCall({ cacheKey, ttl, call }) that composed call()'s resilience
behavior with caching. This didn't fit VernLLM's scope as a resilience layer for LLM calls
specifically, and the generic fn-based form was really a general-purpose memoizer that happened
to live on the LLM client.
cachedLLMCall is renamed to cachedCall. The public cachedCall() now always composes call()
internally, exactly like the old cachedLLMCall did, so cached results get the same
retry/timeout/circuit-breaker behavior as any other LLM call.
// Before
const result = await llm.cachedLLMCall({
cacheKey: `cv:${cvId}`,
ttl: 3600,
call: { systemPrompt, userContent },
});
// After
const result = await llm.cachedCall({
cacheKey: `cv:${cvId}`,
ttl: 3600,
call: { systemPrompt, userContent },
});There is no longer a public way to cache an arbitrary non-LLM function through VernLLM. If you
were using the old fn-based cachedCall({fn}) for general-purpose caching or coalescing
unrelated to an LLM call, that capability has been removed from the package. Use a dedicated
caching library (e.g. async-cache-dedupe) at the application level instead, VernLLM no longer
tries to be a general-purpose cache.
Type renames follow the same mapping:
CachedLLMCallParams<T>→CachedCallParams<T>(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; it now backs a private internal primitive.
See Caching for the current cachedCall reference.
2.0.0: Tool calling
Tool calling introduced two type-level breaking changes, which is why this release is 2.0.0
rather than a minor. Both are narrow: they affect code that reads a ConversationTurn with
certain assumptions, or code that hand-writes an LLMClient implementation instead of using a
built-in adapter. Plain call() usage without tools, and history built from
{ role: 'user' | 'assistant', content } objects, are unaffected either way.
ConversationTurn is now a discriminated union
Previously every entry in history shared one flat shape:
interface ConversationTurn {
role: 'user' | 'assistant';
content: string;
}ConversationTurn is now a discriminated union keyed on role, adding a tool case:
type ConversationTurn =
| { role: 'user'; content: string }
| { role: 'assistant'; content?: string; toolCalls?: ToolCall[] }
| { role: 'tool'; toolResults: ToolResult[] };Constructing turns is unaffected as long as role is already narrowed to 'user' | 'assistant'
(or a matching literal), for example .map((m) => ({ role: m.role, content: m.text })), where
m.role: 'user' | 'assistant'. TypeScript's contextual typing still accepts this against the new
union. A genuinely untyped role: string was already rejected before this change and still is.
What breaks is code that reads a turn. content is no longer guaranteed to be a string on
the assistant branch, it is now string | undefined, since an assistant turn that only
requested tools has no text. Something like turn.content.toUpperCase() after narrowing to
role === 'assistant' now needs a null check first.
An assertNever(turn) style exhaustiveness check in a switch over role also breaks, since
the union now has three members instead of two, and the previously unreachable fallthrough case
is no longer narrowed to never.
See Multi-turn Conversations for the full updated history rules.
LLMClient.messages is a wider union
LLMClient is the interface every built-in adapter implements (fromAnthropic, fromGemini,
fromBedrock, fromFetch, or an OpenAI SDK instance passed straight through), and the shape you
can also implement yourself for a fully custom client. Its messages field widened:
// Before
messages: Array<
| { role: 'system' | 'assistant'; content: string }
| { role: 'user'; content: string | ContentBlock[] }
>;
// After
messages: WireMessage[]; // adds 'tool' turns and tool_calls on 'assistant' turnsThis is unrelated to CacheAdapter/cachedCall, it only concerns the provider-facing interface.
It only affects code that implements LLMClient by hand, bypassing every built-in adapter. If you
wrote an exhaustive switch/if chain over message.role covering only 'system' | 'user' | 'assistant', it may no longer compile, since WireMessage adds a 'tool' case and an optional
tool_calls field on 'assistant'.
To update a custom LLMClient, handle the new tool role (a tool_call_id and content pair)
and read tool_calls off assistant messages when present. If your custom client doesn't need to
support tool calling yet, the minimum fix is exhaustiveness only, add a case that ignores or
rejects role === 'tool', since VernLLM only ever sends one when the caller opted in with
tools.
See Tool Calling for the full feature, and Configuration for the LLMClient field reference.
LLMClient.temperature is now optional
Unrelated to tool calling, but shipping in the same 2.0.0 release since a major bump was already
required. Added a way to opt out of VernLLM's temperature: 0.2 default (temperature: null per
call, or defaultTemperature: null on the instance) so the provider can apply its own default
instead. Making that work required widening the wire-level type:
// Before
temperature: number;
// After
temperature?: number;Same narrow scope as the LLMClient.messages change above: this only affects code that implements
LLMClient by hand. If your custom implementation reads params.temperature assuming it's always
a number, for example calling a method on it without checking whether it's undefined first, it
no longer compiles under strict. Every built-in adapter and every normal call() caller is
unaffected, VernLLM still sends 0.2 by default exactly as before unless you opt out.
To update a custom LLMClient, handle params.temperature being undefined the same way you'd
already handle any other optional field, omit it from the outgoing request, or substitute your own
default before forwarding it. See Configuration for defaultTemperature's full
behavior.
Nothing else changed
Calls that don't set tools keep returning T from call() exactly as before, jsonMode keeps
defaulting to true, and existing plain user/assistant history arrays keep working with no
changes required.