ai-sdk-heal
v0.2.0
Published
Heal broken Vercel AI SDK message arrays before they hit the provider. Fixes orphaned tool calls, missing reasoning signatures, invalid tool names, and other provider rejections.
Maintainers
Readme
ai-sdk-heal

Keep your AI SDK conversations valid. ai-sdk-heal normalizes message arrays so they satisfy each provider's structural rules — pairing tool calls with results, coercing tool inputs to objects, preserving reasoning blocks correctly, and more.
One function. Pure. Idempotent. Safe on the hot path and on persisted history.
import { healMessages } from "ai-sdk-heal";
const { messages, repairs } = healMessages(rawMessages, { provider: "anthropic" });
await streamText({ model, messages });What it does
Providers each have their own rules for what a valid message history looks like:
- Anthropic requires every
tool_useto be paired with a matchingtool_result, reasoning blocks to carry asignature, and rejects assistant messages that contain only reasoning. - OpenAI's Responses API expects reasoning items to be followed by a same-flow item.
- All of them require tool inputs to be objects and tool names to match
^[a-zA-Z0-9_-]{1,64}$.
Agents, retries, and persisted conversations make it easy to drift out of those rules — especially across long multi-turn flows with thinking models and parallel tool calls. ai-sdk-heal checks the whole history against the active provider's rules and returns a normalized copy, plus an audit trail of every change it made.
Rules
| Rule | What it does |
|---|---|
| orphan-tool-use | Assistant tool-call with no matching tool-result: inserts a placeholder result (or drops the call) so the pairing invariant holds |
| orphan-tool-result | tool-result referencing a call that isn't in history: drops it |
| invalid-tool-input | Tool input stored as a raw string because JSON parsing failed upstream: coerces to { raw: "…" } so subsequent turns stay usable |
| invalid-tool-name | Tool names with characters outside ^[a-zA-Z0-9_-]{1,64}$: sanitizes while keeping the call/result pair linked |
| duplicate-tool-result | Same toolCallId appearing twice after a retry: dedupes |
| empty-assistant-message | Assistant message with no substantive content: drops it |
| orphan-reasoning-only-message (Anthropic) | After pruning, an assistant message contains only reasoning blocks: drops it |
| missing-reasoning-signature (Anthropic) | Reasoning block with no providerOptions.anthropic.signature: drops it (Anthropic won't accept thinking without the signature on replay) |
| reasoning-without-following-item (OpenAI) | Trailing reasoning part with no following item in the Responses flow: drops it |
Every change is captured in the repairs array so you can log it, alert on it, or surface it in admin tooling.
Each rule maps to a documented scenario tracked upstream: #8516, #9141, #11602, #13430, #13645, #14259, #8379, #7729, #12504.
Where this fits in the pipeline
ai-sdk-heal operates on ModelMessage[] — the array you pass to generateText / streamText. If you persist conversations as UIMessage[] (the React/UI shape) and call convertToModelMessages, that conversion sits before healMessages:
DB / state AI SDK ai-sdk-heal provider
────────── ────── ─────────── ────────
UIMessage[] ──convertToModelMessages──> ModelMessage[] ──healMessages──> ModelMessage[] ──> Anthropic / OpenAI / …
│ │
└─ pass `ignoreIncompleteToolCalls: true`
to drop UI-level orphans during conversionThe two layers solve different problems:
convertToModelMessages({ ignoreIncompleteToolCalls: true })filtersstate: "input-available"UI parts that haven't received a result yet. Use it for live UI message arrays where the user might have aborted mid-tool-call.healMessagesrepairs anything that survives conversion or that lives only inModelMessage[]form: missing reasoning signatures, invalid tool names, malformed tool inputs, duplicate tool results, OpenAI Responses ordering, persisted DB rows from older SDK versions, and the orphans thatpruneMessagescreates (#13430, #12504).
A defense-in-depth setup combines both:
const modelMessages = await convertToModelMessages(uiMessages, {
ignoreIncompleteToolCalls: true,
});
const { messages } = healMessages(modelMessages, { provider: "anthropic" });
await streamText({ model, messages });Install
npm install ai-sdk-healPeer dependency: ai >= 5.0.
Usage
Heal before the provider call
import { healMessages } from "ai-sdk-heal";
import { anthropic } from "@ai-sdk/anthropic";
import { streamText } from "ai";
const { messages, repairs } = healMessages(rawMessages, {
provider: "anthropic",
onRepair: (r) => logger.info({ repair: r }, "message-normalized"),
});
const result = streamText({
model: anthropic("claude-sonnet-4-20250514"),
messages,
});If you want to hard-fail during development instead:
healMessages(rawMessages, { provider: "anthropic", throwOnRepair: true });Wrap your model once with withHealing
If you'd rather not remember to call healMessages on every request, wrap
the model itself. The wrapper heals the prompt as it passes through the AI
SDK middleware layer:
import { withHealing } from "ai-sdk-heal";
import { anthropic } from "@ai-sdk/anthropic";
const model = withHealing(anthropic("claude-sonnet-4-5"), {
onHealed: ({ repairs }) =>
logger.warn({ repairs }, "prompt-auto-healed"),
});
// Every generateText / streamText call now gets auto-healed.
await streamText({ model, messages });Provider is auto-detected from the underlying model; override via
{ provider: "anthropic" } for custom gateways.
Scope. The middleware runs after the AI SDK's prompt conversion, so it
handles issues only the provider would reject — invalid tool names,
malformed tool inputs, unsigned reasoning, duplicate tool results,
reasoning-without-following-item. Orphan tool calls still need
healMessages up-front, because the SDK validates pairing during its own
convertToLanguageModelPrompt pass. A robust setup combines both:
const healedMessages = healMessages(rawMessages, { provider: "anthropic" }).messages;
await streamText({ model: withHealing(anthropic("claude-sonnet-4-5")), messages: healedMessages });You can also compose healMiddleware manually via wrapLanguageModel:
import { wrapLanguageModel } from "ai";
import { healMiddleware } from "ai-sdk-heal";
const model = wrapLanguageModel({
model: anthropic("claude-sonnet-4-5"),
middleware: [healMiddleware(), otherMiddleware()],
});Validate without mutating
Use validateMessages in tests or CI to assert a conversation is
provider-ready without changing it:
import { validateMessages } from "ai-sdk-heal";
const { valid, issues } = validateMessages(messages, { provider: "anthropic" });
if (!valid) {
// `issues` is the same `Repair[]` shape healMessages returns.
throw new Error(`conversation is not provider-ready: ${issues.map((i) => i.rule).join(", ")}`);
}Heal after pruneMessages
pruneMessages (built into the AI SDK) trims reasoning and tool turns to fit a context window, but in the process it can leave orphaned tool_use blocks (#13430, #12504). Running healMessages after pruning fixes the structure the prune left behind:
import { pruneMessages } from "ai";
import { healMessages } from "ai-sdk-heal";
const pruned = pruneMessages({
messages: history,
reasoning: "before-last-message",
toolCalls: "before-last-message",
});
const { messages } = healMessages(pruned, { provider: "anthropic" });
await streamText({ model, messages });Heal persisted conversations
Because healMessages is idempotent — running it twice produces the same result — it's safe to apply on every read, or as a one-shot migration:
import { healMessages } from "ai-sdk-heal";
for await (const row of db.selectFrom("chat").execute()) {
const { messages, repairs } = healMessages(row.messages, {
provider: row.provider,
});
if (repairs.length === 0) continue;
await db
.updateTable("chat")
.set({ messages, healed_at: new Date() })
.where("id", "=", row.id)
.execute();
}Auto-detect the provider
import { healMessages, inferProvider } from "ai-sdk-heal";
const provider = inferProvider(model);
const { messages } = healMessages(rawMessages, { provider });Policies
Every rule has a default action picked to keep conversations usable. Override any of them:
healMessages(rawMessages, {
provider: "anthropic",
policy: {
orphanToolUse: "drop-call", // default: "stub-result"
invalidToolName: "drop-pair", // default: "rename"
invalidToolInput: "empty-object", // default: "coerce-object"
duplicateToolResult: "dedupe-first", // default: "dedupe-last"
missingReasoningSignature: "keep", // default: "drop-reasoning"
},
});See Policy in the types for every option.
Design
- Pure and idempotent. No side effects, no I/O. Running
heal(heal(x))always equalsheal(x)— this is enforced in the test suite and makes the package safe to apply unconditionally. - Provider-aware. Shared rules run for every provider; provider-specific rules (Anthropic, OpenAI) layer on top.
- Auditable. Every change returns a
Repairrecord with the rule name, message index, and reason. - Composable. Individual rules are exported so you can build your own pipeline.
Notes & caveats
- Tool-name collisions after sanitization. If two distinct invalid tool names normalise to the same string (e.g.
"foo bar"and"foo!bar"both become"foo_bar"), they keep their distincttoolCallIds but share a name. The provider still accepts the conversation; the model can disambiguate via the call IDs. - Google / Gemini. Shared rules apply automatically. We don't ship a Google-specific signature rule because
@ai-sdk/google(≥ the May 2026 release) now auto-injectsskip_thought_signature_validatorfor Gemini 3 tool-call replays at conversion time. Replicating it here would require model-ID detection thatModelMessage[]doesn't carry. - Middleware vs.
healMessages.withHealingruns after the SDK'sconvertToLanguageModelPrompt, so it can't repair orphan tool-use (the SDK validates pairing during conversion and throws first). Always runhealMessageson the message array up-front; usewithHealingas a defensive second layer for everything that slips through.
Related
toolpick— dynamic tool selection for the AI SDK so the model only sees the tools that matter on each step.
License
MIT
