AI SDK 7 Migration: The Production Breakages a Codemod Cannot Fix
Most AI SDK 7 migrations look small in a pull request.
A few option names change. TypeScript points at a callback. The app builds again, the chat page streams text, and the migration is declared complete.
That is enough for a prototype. It is not enough for a production system that calls tools, stores conversation state, retries failures, resumes interrupted runs, or performs external side effects.
I built a provider-free migration lab with two isolated fixtures:
- Node.js
22.18.0 ai@6.0.230ai@7.0.31- strict TypeScript
- deterministic mock language models from
ai/test - no API key, paid request, provider latency, or model-quality variance
The goal was not to benchmark speed. It was to compare control flow and result semantics under identical tool-calling, persistence, failure, cancellation, retry, and timeout scenarios.
Start with the runtime boundary
Before changing any API names, check two requirements that a codemod cannot solve:
- AI SDK 7 requires Node.js 22 or newer.
- AI SDK 7 is ESM-only and no longer supports CommonJS
require().
A minimal package boundary looks like this:
{
"type": "module",
"engines": {
"node": ">=22"
}
}
Your local environment, CI worker, serverless runtime, and production image must all satisfy the same boundary. Otherwise, the migration can pass locally and fail after deployment.
The visible renames are the easy part
The obvious API changes are straightforward:
// AI SDK 6
streamText({
model,
system: 'You are an order assistant.',
onFinish({ text }) {
console.log(text);
},
});
// AI SDK 7
streamText({
model,
instructions: 'You are an order assistant.',
onEnd({ text }) {
console.log(text);
},
});
The event stream also moves from fullStream to stream.
A codemod can help with syntax. It cannot decide what your application meant when it read top-level tool fields, persisted raw SDK messages, retried a side-effecting tool, or handled cancellation after a tool had already completed.
Breakage 1: top-level tool calls no longer mean what some v6 code assumes
Both fixtures ran the same two-step workflow:
user asks for order A-100
↓
model emits lookupOrder tool call
↓
tool returns ready_for_pickup
↓
model returns final answer
Both versions produced the same final text:
Order A-100 is ready for pickup.
But their result shapes differed:
| Field | AI SDK 6 fixture | AI SDK 7 fixture |
|---|---|---|
Top-level toolCalls
|
0 | 1 |
Raw response.messages
|
3 | 1 |
| Final-step tool calls | read from step data | finalStep.toolCalls = 0 |
In AI SDK 7, top-level tool fields represent the full run. Code that previously treated them as “the final step” can silently change behavior.
The migration rule is explicit:
const allRunToolCalls = result.toolCalls;
const finalStepToolCalls = result.finalStep.toolCalls;
Audit every read of toolCalls and toolResults. Decide whether each call site needs full-run history or final-step-only semantics.
Breakage 2: response.messages is not a stable database contract
The same deterministic workflow produced:
- three raw response messages in the v6 fixture;
- one raw response message in the v7 fixture.
That does not make either version wrong. It means the SDK response shape is not the right long-term storage model for your product.
A safer persistence boundary is application-owned:
conversation
└── message
└── run
└── step
└── logical tool_call
├── tool_attempt 1
├── tool_attempt 2
└── tool_result
Persist normalized business records first. Keep the raw SDK payload only as optional audit evidence.
This protects your database from changes in message count, nesting, aggregation, or provider-specific metadata.
Breakage 3: abort is not rollback
I tested three interruption reasons:
page_closednetwork_disconnectedmanual_cancel
Each fixture completed a tool call, committed the result, and then aborted before final text generation.
The invariant was:
- the user message survives;
- the completed tool result survives;
- the final assistant response is absent;
- the run is marked
aborted; - the tool executes exactly once.
Once an external side effect has succeeded, closing the page does not undo it. Recovery must resume from persisted state, not replay the tool blindly.
For every side-effecting tool, use an idempotency key such as:
tenant_id + run_id + logical_tool_call_id
Breakage 4: provider retries and tool retries are different policies
A provider retry repeats model I/O. A tool retry may repeat a payment, email, database write, deployment, or ticket creation.
The fixture models one logical tool call with two attempts:
attempt 1 → transient failure
attempt 2 → success
one successful result returned to the model
Track attempts separately from the logical call:
type ToolAttempt = {
logicalCallId: string;
attempt: number;
status: 'running' | 'failed' | 'succeeded';
error?: string;
};
Do not let an SDK step double as your retry ledger.
Breakage 5: timeout needs more than one budget
The v6 fixture uses an application-owned timer and AbortController.
The v7 fixture directly verifies a first-class total timeout:
const result = await generateText({
model,
prompt,
timeout: {
totalMs: 20,
},
});
AI SDK 7 also exposes broader timeout controls in its public API. In production, separate budgets for the whole run, individual steps, idle chunks, and tools. Verify the exact type definitions for the version in your lockfile, and add a deterministic fixture before relying on a new timeout field.
A timeout is not complete until it is represented in persisted state and propagated through providers and tools.
Real HTTP/SSE disconnects change the recovery problem
The deterministic fixtures prove state transitions, but I also added a real localhost HTTP/SSE test using Node's native server and fetch. The client disconnected immediately after receiving a completed tool result.
In request-bound mode, the observed state was:
| Field | Result |
|---|---|
run.status |
aborted |
| Tool executions | 1 |
| Tool result saved | true |
| Final response saved | false |
| Disconnect observed | true |
Recovery reused the same run_id and idempotency key. The application checked the tool ledger before execution and reused the stored result:
tool executions before resume = 1
tool executions after resume = 1
duplicate executions = 0
final response saved = true
That ordering matters. The user message, planned tool call, executing attempt, and completed tool result should be committed independently of the final assistant response. Otherwise, a broken stream can erase the evidence needed to avoid replaying a side effect.
consumeStream() is useful for continuing server-side stream consumption and completion callbacks after a client disconnect. It does not know whether an order, email, deployment, or CRM update has already happened. It cannot replace an application-owned tool ledger and idempotency check.
I also tested a detached mode in which the run was not cancelled with the HTTP connection. The client disconnected, while the run completed with one tool execution and a saved final response. This proves that request and execution lifecycles can be separated. It does not turn an in-process promise into a durable job: process restarts, deployments, instance eviction, and delayed approvals still require a queue, worker, or durable workflow owner.
chunkMs and Cloudflare 524 are different timeout layers
A local reverse proxy used a deliberately tiny 70 ms read-silence budget:
slow first byte -> simulated 524
heartbeat every 35ms -> 200
This was not a real Cloudflare edge test: there was no public domain, Ray ID, or production edge node. It only verifies the boundary between application state and proxy read behavior.
The controls are separate:
| Layer | What it controls | What it cannot replace |
|---|---|---|
| Browser/request | Page close or network disconnect | Knowledge of whether a tool completed |
AI SDK chunkMs
|
Silence between stream chunks | A reverse proxy's read budget |
AI SDK toolMs
|
One tool's execution budget | Side-effect reconciliation |
| Cloudflare 524 | Proxy waiting for the origin response | Persisted run and step state |
A heartbeat can keep a proxy connection active, but it does not prove business progress. A useful heartbeat should include a run_id, step_id, status, and update time, and it still needs a total SLA. Tasks that may outlive the HTTP budget should return a job_id, persist each step and tool result, and continue under a queue or durable workflow.
A safer rollout order
The migration sequence I would use is:
- Upgrade local development and CI to Node.js 22 and ESM.
- Run the official migration tooling for mechanical renames.
- Audit all reads of top-level tool fields.
- Move final-step-only logic to
finalStep. - Introduce version-neutral persistence.
- Propagate
AbortSignalthrough providers and tools. - Add idempotency keys and attempt-level retry records.
- Add deterministic cancellation and timeout fixtures.
- Migrate one internal workflow behind a feature flag.
- Compare normalized events before moving production traffic.
Reproducible evidence
The complete dual-version fixture, raw JSON results, migration diff, and persistence architecture are public:
https://github.com/xbstack/xbstack-ai-sdk-7-migration-demo
The full production migration guide, including the complete schema and rollback checklist, is available here:
The main conclusion is simple:
AI SDK 7 is worth upgrading to, but a safe migration begins by separating application state from SDK state.
A build that passes after a codemod proves syntax compatibility. It does not prove that your tools, persistence, cancellation, retries, or recovery semantics are still correct.
Top comments (0)