VernLLMVernLLM

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 native image_url shape inside fromOpenAICompatible. A raw client gets VernLLM's internal shape passed straight through, not OpenAI's.
  • Streaming. The OpenAI SDK's create returns an async iterable when called with stream: true rather than exposing a separate createStream method, so a bare client has no createStream at all. stream: true against an unwrapped client throws LLMError('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 for cachedCall() without tools).
  • CachedLLMToolCallParams<T>CachedToolCallParams<T> (public type for cachedCall() with tools).
  • The old generic CachedCallParams<T> (the fn-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' turns

This 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.

On this page