AI Engineering
tutorial
Featured

A New Model Dropped This Week. Don't Just Bump the String.

Four frontier models shipped in the last 72 hours and your instinct is to change one env var and redeploy. That one-line model swap is the riskiest change you will make this quarter, because tool-calling, output format, cost, and the prompt you tuned all shift at once and nothing throws. Here is how to treat a model upgrade like the dependency change it actually is: pin it behind an adapter, gate it on an eval run against real traffic, and shadow it before you flip.

Viral Ruparel
11 min read
Share:

Sometime in the last three days a new frontier model landed, probably more than one, and somewhere on your team a pull request went up that changes a single line. The model id in a config file moves from last quarter's version to this week's. The diff is three characters. It passes review in about eight seconds because there is nothing to review. Then it ships to every user at once.

That three-character diff is the most dangerous change your team will make this quarter, and the reason is that nothing about it looks dangerous. The request still succeeds. The response still parses. The agent still completes its loop. What actually changed lives underneath all of that: the new model formats its tool calls a little differently, it is more verbose so every response costs more tokens, it now follows an instruction the old model quietly ignored, and the prompt you spent two weeks tuning against the old model's specific habits is now tuned against a model that no longer exists. None of that throws. You do not get an alert. You get a slow bleed that shows up days later as "answers feel worse lately" or a finance ping about spend, and by then the deploy that caused it is buried under twenty others.

The framing that fixes this is simple. A model version is a dependency, the single most behavior-defining dependency your system has, and you upgrade it the way you would upgrade a database engine or a crypto library, not the way you bump a patch release of a formatting tool. That means three things: the version is pinned behind a boundary you control, the upgrade has to pass an eval gate on real traffic before it is allowed to promote, and the promotion is a canary you can roll back in one move, not a flip that hits everyone. Let me walk through each.

Pin the version behind an adapter, not in a call site

The first problem is that most codebases have the model id sprinkled across the places that call the API, sometimes as a string literal, sometimes read straight from an env var at the call site. When you cannot see every place a model is named, you cannot reason about what an upgrade touches, and you certainly cannot run the old and new versions side by side because there is no single place that decides which one runs.

So the first move is to give the model a name that is yours, not the provider's. Your code asks for the chat model or the extraction model, and a registry maps that role to an exact, pinned version. Nothing in your application ever types a provider version string.

// model-registry.ts
// Roles your app knows about. Application code references these, never a
// provider version string. Every id here is pinned to an exact version, not a
// floating alias like "-latest", so an upstream change can never move your
// baseline without a deploy you reviewed.
type Role = "chat" | "extraction" | "cheap";

interface ModelBinding {
  provider: "anthropic" | "openai";
  model: string;        // exact pinned version
  maxOutputTokens: number;
}

const REGISTRY: Record<Role, ModelBinding> = {
  chat:       { provider: "anthropic", model: "claude-opus-4-8",       maxOutputTokens: 4096 },
  extraction: { provider: "anthropic", model: "claude-haiku-4-5-20251001", maxOutputTokens: 1024 },
  cheap:      { provider: "openai",    model: "gpt-5-mini-2026-05-01", maxOutputTokens: 1024 },
};

export function bindingFor(role: Role): ModelBinding {
  return REGISTRY[role];
}

Two details in there matter more than they look. The first is that every id is a fully pinned version, never a floating -latest alias. A floating alias means the provider can move your production baseline for you, silently, with no deploy and no review, which is the exact failure this whole post is trying to prevent. The second is that the registry, not the call site, owns operational knobs like maxOutputTokens, because those often need to change when the model does. A chattier new model with the old token ceiling will get cut off mid-answer, and you want that decision in one file next to the version, not scattered.

With this boundary in place, an upgrade becomes a change to one row in one table, and more importantly you can now run two bindings at once, which is what the next two steps need.

Gate the upgrade on an eval run against real traffic

Now the actual upgrade. You have a candidate version and you want to know, before any user sees it, whether it is better, worse, or differently-shaped than what you run today. The cheapest high-signal way to learn that is not a public benchmark. It is your own traffic.

Sample a few hundred real requests from your logs, the inputs plus the outputs your current model produced, replay them through the candidate, and compare. This tests the upgrade on your actual distribution of prompts, your formats, your edge cases, which is exactly where a benchmark score tells you nothing. You are looking for three things at once: did quality hold, did the output shape stay stable, and what did cost and latency do.

// eval-gate.ts
import { bindingFor } from "./model-registry";
import { runModel } from "./runner";           // your thin provider client
import { scoreQuality } from "./judge";         // rubric scorer or LLM-as-judge
import { validatesAgainstSchema } from "./schema";

interface Sample { input: ChatInput; baselineOutput: string; }

interface GateResult {
  passed: boolean;
  avgQuality: number;
  schemaValidRate: number;
  costDeltaPct: number;
  regressions: { input: ChatInput; note: string }[];
}

// Promotion is allowed only if quality does not drop, structured output stays
// valid, and cost has not blown past a budget you set on purpose.
export async function runGate(
  candidate: { provider: string; model: string },
  samples: Sample[],
): Promise<GateResult> {
  let qualitySum = 0, schemaValid = 0, candCost = 0, baseCost = 0;
  const regressions: GateResult["regressions"] = [];

  for (const s of samples) {
    const out = await runModel(candidate, s.input);

    const q = await scoreQuality(s.input, out.text, s.baselineOutput);
    qualitySum += q;

    // If this role returns structured data, a parse failure is an instant fail
    // for that sample regardless of how good the prose looks.
    const validShape = validatesAgainstSchema(out.text);
    if (validShape) schemaValid++;
    else regressions.push({ input: s.input, note: "schema invalid on candidate" });

    if (q < 0.6) regressions.push({ input: s.input, note: `quality ${q.toFixed(2)}` });

    candCost += out.costUsd;
    baseCost += estimateBaselineCost(s);
  }

  const n = samples.length;
  const avgQuality = qualitySum / n;
  const schemaValidRate = schemaValid / n;
  const costDeltaPct = ((candCost - baseCost) / baseCost) * 100;

  const passed =
    avgQuality >= 0.75 &&        // hold the quality bar
    schemaValidRate >= 0.99 &&   // structured output must stay parseable
    costDeltaPct <= 20;          // a 20% cost jump needs a human decision

  return { passed, avgQuality, schemaValidRate, costDeltaPct, regressions };
}

The thresholds are yours to set and the point is that they are explicit. A twenty percent cost increase is not automatically a blocker, but it is automatically a decision a person makes on purpose rather than a surprise finance finds next month. This is the same discipline as running evals in CI to catch regressions, pointed specifically at the model-swap event, and it leans hard on your structured outputs actually being validated, which is its own job for a schema gateway at the boundary. If the candidate cannot clear the gate, the upgrade stops here and you have a readable list of exactly which inputs regressed, days before any of them could have reached a user.

Promote with a shadow, then a canary, never a flip

Passing the gate on replayed traffic is necessary but it is not the same as being safe on live traffic, because replay cannot capture everything, concurrency, real user follow-ups, the long tail of weird inputs that only production produces. So the promotion itself is staged, and the boundary you built in step one is what makes staging cheap.

Start in shadow. Live requests go to the current model as always and the user gets that answer, but a copy of the request also goes to the candidate and you log both outputs without ever showing the candidate's to anyone. This gives you a real-traffic comparison with zero user risk. When shadow looks clean, move to a canary: route a small, growing slice of real traffic to the candidate for real, watch your quality and cost signals, and keep a kill switch that is a config change, not a redeploy.

// rollout.ts
import { bindingFor } from "./model-registry";
import { runModel } from "./runner";
import { logShadowPair } from "./telemetry";

// Read at request time from config you can change without shipping code, so a
// rollback is flipping a number, not waiting on a deploy pipeline.
interface Rollout { candidate?: { provider: string; model: string }; canaryPct: number; shadow: boolean; }

export async function serve(role: "chat", input: ChatInput, rollout: Rollout) {
  const current = bindingFor(role);

  // Shadow: user always gets the current model; candidate runs for comparison
  // only and its result is never returned. Fire and forget, never awaited on
  // the hot path.
  if (rollout.shadow && rollout.candidate) {
    const cand = rollout.candidate;
    runModel(cand, input)
      .then((shadowOut) => logShadowPair(input, shadowOut))
      .catch(() => {/* a shadow failure must never affect the real response */});
  }

  // Canary: a slice of real traffic actually gets the candidate.
  const useCandidate =
    rollout.candidate && !rollout.shadow && Math.random() * 100 < rollout.canaryPct;

  const binding = useCandidate ? rollout.candidate! : current;
  return runModel(binding, input);
}

Notice what rollback costs here. It is setting canaryPct back to zero in a config store, which takes effect on the next request. You are never in a position where recovering from a bad model means reverting a commit and waiting fifteen minutes for a build while every user gets the worse answer. The ramp goes something like shadow for a day, then one percent, then ten, then fifty, then a hundred, and at any step a bad signal sends it straight back to zero. Only after a hundred percent has held for a while do you actually change the registry row and delete the rollout config, which is the moment the upgrade is truly done.

The things that will bite you

Prompt cache invalidation. If you rely on prompt prefix caching to cut cost and latency, be aware that changing the model throws the entire cache away, because a cache entry is per model version. Your first hours on a new model run at full uncached price and latency, which can look like a regression that is really just a cold cache. Expect it, and do not judge cost on the first hour. This interacts directly with how prefix caching saves you money in the first place.

The prompt was tuned to a ghost. Your system prompt has accumulated little phrasings that exist only to correct the old model's specific quirks, a "do not apologize," a "always return valid JSON," a nudge it needed and the new one does not. Some of those are now dead weight and a few may actively fight the new model. A model upgrade is the right moment to re-earn each of those lines, not carry them forward on faith.

Tool-calling is where format drift hides. If your agent uses tools, the shape and reliability of tool calls is exactly the kind of thing that shifts between versions without any error. Put tool-call format and success rate in your gate explicitly, because a model that is better at prose can still be worse at picking the right tool with the right arguments, and that failure is invisible to a quality rubric that only reads the final text.

Migration is not routing. Keep the two ideas separate in your head and your code. Routing sends different requests to different models to cut cost; migration moves your whole baseline forward. They share the adapter and the eval suite, but a routing mistake touches a slice of traffic while a migration mistake touches all of it, which is why migration earns the canary.

The takeaway

The pull request that bumps a model id is small, and the temptation is to treat the change as small too. It is not. It is the one dependency in your stack that redefines how everything above it behaves, and it changes that behavior without raising its hand. The fix is not caution, it is structure: name the model by role so the version lives in one place you control, gate every upgrade on a replay of your own traffic so quality and cost regressions are a diff and not an incident, and promote through shadow and canary with a rollback that is a number you change, not a build you wait on. Do that and a new model launch becomes a thing you adopt on a Tuesday with confidence, instead of a thing that adopts you.

If a model your product depends on just shipped a new version and nobody owns the question of how you prove the upgrade is safe before it reaches users, that gap is where a quiet quality drop or a cost surprise turns into a real problem. Book a consultation call and we can put an eval gate and a staged rollout around your model versions so the next launch is a routine deploy.

Viral Ruparel

Generative AI consultant helping teams ship reliable LLM and agent systems in production.

Contact Viral about your AI project →

Frequently Asked Questions

Why not just swap the model id and watch for errors in production?+

Because the failures a model upgrade causes almost never surface as errors. The request still returns a 200, the JSON still parses, the agent still runs its loop. What changes is quieter: the new model formats a tool call slightly differently, gets more verbose so your token bill climbs, obeys an instruction the old one ignored, or drops a habit your prompt was quietly relying on. None of that trips an exception or an alert. You find out when a customer reports that answers got worse or finance asks why spend jumped, which is days later and much harder to trace back to the deploy. An eval run on real traffic samples catches the regression before the flip, when it is a diff you can read instead of an incident you have to reconstruct.

How is this different from routing between models per request?+

Routing decides which model handles a given request at runtime, usually to send cheap work to a cheap model and hard work to a strong one. Migration is the separate question of moving your baseline from one model version to a newer one for good. They share machinery, an adapter boundary and an eval suite, but the risk is different. A routing mistake affects the subset of traffic that matched the rule. A migration mistake affects everything, because you changed the default under every path at once. That is exactly why the promotion needs a gate and a canary rather than a config edit.

Do I need a golden dataset before I can upgrade safely?+

You need something, but it does not have to be a curated golden set on day one. The highest-signal cheap start is a few hundred real requests sampled from your own logs with their old-model outputs stored alongside. Replay those through the candidate model and diff the results. That tells you how the upgrade behaves on your actual distribution, which a synthetic golden set often misses. Add a small hand-labeled set for the cases you care most about and grow it over time. The point is to have a repeatable check you run before every model change, not to block the first upgrade on a perfect benchmark.