Keep Vercel AI SDK model swaps to one line by putting each model behind a role-named handle, with the AI Gateway as the production routing layer beneath it.
You shipped the invoice chat in spring on one provider’s model. By autumn a competitor releases a model that is cheaper and better at the arithmetic your chat box leans on, and someone asks the obvious question: can we move to it?
That should be a one-line diff. Whether it is depends on one decision you made in spring, probably without thinking about it: where did you type the model’s name?
Type it at the call site, next to each prompt, and “move to the cheaper model” becomes a grep across every route, test, and fixture, including the one you will forget until it breaks. That is a multi-day change for what should be a single character. And the provider landscape keeps moving: a major model from a new vendor lands almost every month, so a web app that cannot swap models cheaply pays this cost on every release.
By the end of this lesson, a model swap is a one-line edit in one file, and you will know when to put the AI Gateway behind it for production. This is the same discipline you use for the Drizzle client in lib/db/index.ts: one file owns the configuration, and every other file imports a named export. The lesson is about where the model name lives and what sits behind it, not how to write the call, so every generation line stays elided.
How you name a model: gateway string or provider object
Every generation call the SDK gives you, streamText, generateText, and generateObject, takes a model argument, and the body of the call is identical no matter which provider sits behind it. Same function name, same Zod schema for generateObject, same tool definitions. Switching vendors changes only the value of model. That uniformity is the whole reason to use the SDK, and it is why a provider’s own SDK locks you in: it speaks that vendor’s call shape, so switching rewrites the call rather than a string.
There are two ways to name a model, and the difference is worth getting right the first time.
A plain 'creator/model' string. No provider package installed and no import: the SDK routes this through the Vercel AI Gateway by default. This is the 2026 default and the form to reach for.
import { openai } from'@ai-sdk/openai';
const result = streamText({
model: openai('gpt-5-mini'),
// model call — Chapter 106
});
A provider object from an installed package (@ai-sdk/openai) that talks to the vendor directly and bypasses the gateway. Reach for this only when you need a provider-specific option the gateway string doesn’t expose. It is the escape hatch, not the default.
The string form leans on the SDK’s global provider : hand it a 'creator/model' string and it routes the call through the AI Gateway for you. The gateway isn’t something you bolt on later; the moment you write a plain model string, you’re already going through it. The provider object is the exception, for the rare case you need an option the string doesn’t surface yet.
The string form already buys a one-line swap. The SDK doesn’t care which model string you pass, so editing 'openai/gpt-5-mini' to 'anthropic/claude-...' at a call site really is one line. The problem is the second call site, and the third.
Picture that string typed inline everywhere it’s used. Five surfaces now reach for the model: the chat, a summarizer, an extractor, and two background jobs, each with the string typed into it. The swap is five edits, plus the test fixtures, plus the route someone shipped last week that isn’t in your head. A short string doesn’t help, because the cost is the number of places you touch, and length has nothing to do with that. It’s why you don’t write new Pool(...) atop every file that runs a query: the Drizzle client lives in one place, and everything imports it.
The model gets the same treatment. A single module, lib/llm/models.ts, exports named handles bound to model identifiers, and every call site imports a handle instead of typing a string. A swap becomes a one-line edit in models.ts, with no call site changed. The route you forgot about imported the handle too, so it moves with everything else for free.
This module is the seam to the model: it reads provider config and routes calls. Like every secret-touching adapter in lib/, it opens with import 'server-only';, which turns an accidental import from a Client Component into a build error instead of a key leaked into the browser bundle.
The handles are plain 'creator/model' strings, routed through the gateway. This right-hand side is the only thing a swap touches: change the string, and every call site that imports the handle now points at the new model, untouched.
camelCase, not SCREAMING_SNAKE_CASE. The instinct is to shout a module-level constant, but these are runtime configuration, not compile-time constants, and the conventions carve out camelCase for model handles. Resist the caps.
One export per role the model plays. The embedding handle lives here with the others, but its swap carries a caveat the chat handles don’t, and a different kind of cost.
1 / 1
This file has siblings you already trust. lib/db/index.ts owns the database client. lib/llm/pricing.ts, the price table you built in Bounding LLM spend, owns the per-model cost map keyed by the model each handle points at, so the two files move together on a swap: change the handle here, add the new model’s price there. lib/rate-limit.ts owns the limiter, lib/auth.ts owns auth. One concept, one file, every time. The model is just the newest member of that set.
Centralizing the model strings in one file survives a version bump but not a provider switch, and the switch is the harder case.
Suppose the file exports handles named after their vendors: gpt5ForChat, openaiFast, claudeSummarizer. The strings live in one place, but the vendor is now baked into each name. Move chat from OpenAI to Anthropic and gpt5ForChat names OpenAI while pointing at Claude. You either rename it across every import, the same grep the central file was meant to delete, or you leave a misleading name in place. The vendor has leaked out of the call site and into the name.
The fix is to name the role the model plays, never the provider behind it.
The name hardcodes the vendor. Move chat off OpenAI and gpt5ForChat becomes a lie, so now you rename the variable and every import, which is exactly the grep the central file was supposed to delete.
Same right-hand side, honest left-hand side. Swap the string to any vendor and the name still tells the truth: “the chat model” is still the chat model. The swap stays one line.
A role name like fastModel, summarizerModel, or chatModel describes a capability, and a capability holds across a swap: the fast model is still the fast model once you change which vendor provides it, and only the string moves. The call site asks for a capability (“give me the fast one”), not a vendor. Capabilities are stable; vendors churn.
Sort each handle name by whether it survives a provider swap.
Sort each model handle by whether its name survives a switch to a different provider.
Drag each item into the bucket it belongs to, then press Check.
Role-named (good)Names the job; survives a swap
Vendor-named (leaks)Names the vendor; breaks on a swap
You won’t add the gateway here, because the plain model string already routes through it. The real question is whether to configure it for production, or leave the bare default alone.
A bare string, routed with nothing configured, is plenty for a prototype. Once you lean on it, configuring the gateway adds four production features the bare SDK doesn’t ship on its own, one concern each:
Automatic failover. When the primary provider returns a 429, a 5xx, or times out, the gateway retries the next provider in a fallback list, and your code never sees the error. This closes last lesson’s provider-429 problem: the catch branch you were told you’d delete is gone.
Observability. Latency, error rate, cost-per-request, and per-user attribution, without instrumenting a single route. This is the infrastructure view of spend, next to the app-level view your operator dashboard already gives you from its Drizzle query over the audit log. One sees your app’s accounting, the other sees the raw provider traffic; neither replaces the other.
Unified billing. One invoice across every provider, instead of N vendor relationships and N cards to reconcile.
BYOK key management.BYOK lets you hold your own provider keys at the gateway boundary, so they never sprawl across your app’s environment on every deploy target.
So when does configuring it stop being optional? Like the trigger thinking in When a feature needs an LLM, the default holds until a threshold crosses it. Any one of these three is enough:
Live traffic depends on the surface. A user-facing surface can’t absorb a provider outage gracefully without failover. The day the primary provider has a bad hour, your feature does too, unless the gateway routes around it.
Multi-model routing is part of the product. A fast model for autocomplete, a smart model for the long draft. The gateway routes between them in one place, instead of N branches across your call sites.
Cost observability is a product requirement. The operator needs per-user, per-surface spend, and the gateway exposes it without per-route instrumentation.
Until one fires, the bare string-through-gateway default is enough. Production is the same string plus a fallback list plus someone reading the dashboard.
Seeing the whole lesson as a stack makes the separation concrete, because each layer absorbs a different kind of change.
Application coderoute handler / Server Actionimports a role handle, never a model string
lib/llm/models.ts
absorbs role changes
the named handle — a new role is one new line here
AI SDK callstreamText, generateObject, …
absorbs provider differences
one call shape — only the model value moves
AI Gateway
absorbs availability + observabilityfailover, metrics, unified billing
Providersdoes the work
primaryfallback Afallback B
Four layers, four kinds of change — each layer absorbs exactly one axis of
churn, so a change on one axis never ripples to the others.
A new role (“we need a summarizer”) touches only models.ts. A vendor swap is absorbed by the SDK’s uniform call shape, a provider outage by the gateway’s failover. The provider just does the work. Four axes of churn, four layers, and a change on one never ripples to the others.
Failover is the feature people most often try to hand-roll, and the hand-rolled version is exactly the duplicated code this chapter warns against. There are two ways to get it. Compare the shape of each.
It works, but this same block has to be copied into every surface that calls a model. Every new route is one more place to remember it, the same bug class as a forgotten auth check or a missing token cap. A structural problem isn’t solved by a block you have to remember to paste.
The fallback chain is declared once, as configuration. The gateway tries the primary, then each fallback in order on failure: no try/catch, nothing to copy into the next route. It is declared in one place and applied everywhere that imports these handles.
The fallback handles, smartFallbackA and friends, are role-named entries that belong in models.ts alongside the primaries, so the central-file discipline stays consistent.
So prefer the gateway: route-level catch-and-retry is duplicate code the gateway deletes.
The model name is configuration; so is the key that authorizes the call, and the key has sharper edges.
Provider keys live in env, validated through the same @t3-oss/env-nextjs + Zod seam in env.ts you’ve used since the database chapter.
The names follow one convention: OPENAI_API_KEY, ANTHROPIC_API_KEY, AI_GATEWAY_API_KEY.
The AI SDK auto-reads <PROVIDER>_API_KEY from process.env for its first-party providers, so you never pass a key at the call site, which is why a handle is a model string and nothing more.
Behind the gateway, only the gateway’s own key (AI_GATEWAY_API_KEY, or a deployment OIDC token) is needed at the boundary, and the per-provider keys move into the gateway’s BYOK config.
Adding them to the schema is the whole job:
src/env.ts
export const env = createEnv({
server: {
OPENAI_API_KEY: z.string().min(1),
ANTHROPIC_API_KEY: z.string().min(1),
AI_GATEWAY_API_KEY: z.string().min(1),
},
// ...client, runtimeEnv
});
Three things you never do with a provider key. These aren’t warnings to remember; the SDK’s shape already enforces them, the way React hooks force the server seam for free.
Never read a key from a database row. Env is the canonical seam for configuration; the database is for tenant-scoped data. A key in a table is a key in a backup, in a logged query, one bad join away from a response.
Never accept a key from a query parameter or request body. It would land in access logs, browser history, and Referer headers, three places you can’t fully scrub.
Never expose a key to the browser. You can’t do this by accident: the SDK’s hook-based shape (useChat, useCompletion) forces the call onto the server, and only NEXT_PUBLIC_* reaches the client.
A missing OPENAI_API_KEY should fail pnpm build through the env validator, the same way a missing DATABASE_URL does, rather than returning a 5xx the first time real traffic hits the surface.
Every swap so far has been one line. There is one place where that story breaks, and it breaks expensively.
Embeddings are not portable across providers.
An embedding only means anything inside the exact model that produced it. A vector you indexed with one provider’s embedding model cannot be queried against another provider’s embeddings, because the two models map text into different, incompatible vector spaces , where distances measured across them carry no information. This holds even within the same vendor, and even at the same dimension count: swap one embedding model for a newer version without re-embedding your corpus and your search recall can collapse to near zero, every result a near-random miss. Teams have broken their own search this way in a single deploy.
So the embeddingModel handle in models.ts is a one-way commitment until you re-embed everything. Swapping smartModel to a different vendor is a one-line edit you ship today. Swapping embeddingModel is a re-indexing project: re-embed every stored document, rebuild the vector index, plan a migration window, and pay to run your whole corpus back through a model. Same file, same-looking line, wildly different blast radius. One place still owns the handle; what differs is the cost of pulling it, and conflating the two is the trap.
Each claim is about how cheaply a given model handle can be swapped.
Mark each statement True or False.
Swapping smartModel to a different vendor is a one-line edit in models.ts.
Chat models share the SDK’s uniform call shape, and the call site asks for a capability, not a vendor — so only the string on the right-hand side moves. Nothing at the call sites changes.
Swapping embeddingModel to a different vendor is a one-line edit.
The handle changes in one line, but the vectors already in your index were produced by the old model. Querying them against the new model is meaningless until you re-embed the whole corpus — a re-indexing project, not a config change.
A vector indexed with provider A’s embedding model can be queried against provider B’s embeddings.
Different models map text into different vector spaces. The coordinates from one are meaningless in the other, so distances measured across them carry no information.
Embeddings from two different versions of the same vendor’s embedding model are interchangeable.
Non-portability holds even within one vendor and at the same dimension count. A version bump can drop search recall to near zero unless you re-embed the corpus.
Round complete
Reveal card-by-card review
You’ll build embeddings in the next chapter. The takeaway is narrow and durable: the clean swap story for chat models does not extend to embeddings, so price an embedding swap as a migration, not a config change.
Structured output swaps more cleanly than tuned prompts
How you shape the call decides how cheaply it swaps.
A surface built on generateObject with a Zod schema returns typed data no matter which provider is behind it. The schema is the contract; the model is the implementation. Swap vendors and the schema absorbs the small differences in how each provider shapes its output, so the same schema validates whatever model you point at. The swap is clean because the contract never moved.
A surface built on streamText with a prompt-engineered free-form response is fragile. That prompt was tuned to one model’s quirks: its phrasing, formatting habits, and tone. Point it at a different model and the output can shift in ways no type-check catches: worse formatting, a different structure, a quietly degraded answer. The code compiles, the swap “works,” and quality drops on a dimension the compiler never sees.
So when the workload is structured, such as extraction, classification, or form-fill, the second and third triggers from the first lesson, reach for generateObject: the abstraction wins are larger and the swap is cleaner. Free-form streamText is right for genuine prose, but you trade away some swap-portability to get it.
If you’ve gone looking, you’ve seen other ways to wire a model into a Next.js app. Here is where they sit.
Raw provider SDKs
These lock the call site to one vendor’s shape, so a swap rewrites the call rather than a string, and you lose the unified streaming model. Reach for one only to access a provider feature the AI SDK hasn’t surfaced yet, which is rare in 2026.
LangChain
A heavier programming model (chains, agents, retrievers) that fights React Server Components and the App Router’s streaming primitive. It fits research-style multi-agent orchestration off the user-request path, not a Next.js SaaS surface.
The AI SDK is the canonical Next.js integration. These are the narrow cases where the alternatives fit, not competitors for the surface you’re building.
Picking the provider, leaning on the gateway, and committing to an embedding model each pass the three-test rule for an architectural decision: each touches multiple files (the route, models.ts, env.ts, billing, the vector index), each has reasonable alternatives (no LLM, a different provider, a hosted RAG service), and each costs more than one pull request to reverse, the embedding commitment most of all, since undoing it is a re-indexing project. That signature earns an ADR , one per decision. The course’s running app owns no real provider commitment, so there is none to hand in here. The provider churns; your call sites don’t.