How I Migrated a Lambda AI App to Bedrock’s OpenAI-Compatible APIs Without Rewriting Everything
Blog article/Blog archive

How I Migrated a Lambda AI App to Bedrock’s OpenAI-Compatible APIs Without Rewriting Everything

A practical migration story about moving a Lambda AI app from a custom OpenAI-style integration to Amazon Bedrock without rewriting the whole codebase

Jun 6, 202611 min read0 comments43 views
AWSAILambdaBedrockServerless

Draft note: This article is drafted for review only. It will stay out of the public blog index until I publish it.

I wanted to move one of my Lambda AI apps off the old setup and onto Amazon Bedrock without turning it into a rewrite project.

That mattered because the app already worked. It accepted a request, built a prompt, called a model, handled retries, logged traces, and returned a response. The architecture was fine. The problem was the underlying model provider path had started to feel like something I would rather replace before it turned into a dependency headache.

Bedrock’s OpenAI-compatible APIs looked like the cleanest exit. The pitch was simple: keep the shape of the client calls familiar, switch the backend, and avoid rewriting the whole application around a new inference layer.

That is the kind of migration I like. Not dramatic. Just boring in the best possible way.

What the app looked like before the migration

The app was a pretty standard Lambda-backed API. A request came in through API Gateway, Lambda normalized the payload, then the handler called a model provider and returned the result.

The code already had a few production-minded pieces around it:

  • request IDs and trace IDs
  • structured JSON logs
  • retry logic for transient failures
  • basic token accounting
  • response formatting that separated raw model output from user-facing output

That structure mattered because it meant the migration target was not the whole app. It was mostly the model client and the surrounding config.

Here is the important part: I was not trying to change the product behavior. I was trying to change the model integration while keeping the request flow, error handling, and observability intact.

Why Bedrock made sense here

I was not looking for novelty. I was looking for less friction.

Bedrock gave me a few things I wanted right away:

  • one AWS-native place to manage model access
  • permission control through IAM instead of a separate provider key story
  • the ability to keep the app inside my existing AWS boundary
  • a path that felt close enough to the OpenAI-style request shape to reduce code churn

That last point was the big one. If a migration forces me to rewrite every prompt wrapper, every request mapper, and every retry branch, the cost is usually not worth it unless there is a very strong reason.

In this case, the compatibility layer meant I could keep the application structure and mostly swap the backend implementation.

What stayed the same

These parts stayed the same, which is why the migration was manageable:

  • The handler shape. The Lambda entrypoint still accepted the same kind of request and returned the same response format.
  • The prompt structure. My system and user message construction did not need a redesign.
  • The retry strategy. I still handled transient errors in the same place.
  • The observability model. Request IDs, trace IDs, and JSON logs all stayed in place.
  • The deployment path. The app still deployed as a Lambda artifact through the same pipeline.

That is the real reason the migration stayed small. I treated the model client as an adapter, not as the center of the application.

What I actually had to change

The changes were smaller than I expected.

At a high level, I changed four things:

  1. the model client configuration
  2. the authentication path
  3. the request endpoint or model identifier mapping
  4. the tests that assumed the old provider behavior

The client code became a thinner wrapper around the Bedrock-compatible request shape. I did not want provider-specific logic leaking into the rest of the app, so I kept all of that behind one interface.

A simplified version looked like this:

type LlmRequest = {
  prompt: string;
  model?: string;
  temperature?: number;
};

type LlmResult = {
  text: string;
  model: string;
  promptTokens?: number;
  completionTokens?: number;
};

export async function generateText(input: LlmRequest): Promise<LlmResult> {
  const client = getBedrockClient();

  const response = await client.send({
    prompt: input.prompt,
    model: input.model ?? 'claude-sonnet-4',
    temperature: input.temperature ?? 0.2,
  });

  return {
    text: response.text,
    model: response.model,
    promptTokens: response.usage?.inputTokens,
    completionTokens: response.usage?.outputTokens,
  };
}

The important thing is not the exact SDK syntax. It is the shape of the boundary. The rest of the app only cared that it could ask for text and get text back.

That kept the migration honest.

What broke first

Every migration has a few annoying surprises.

The first one was auth. Even when the API shape looks familiar, the authentication story is different. Instead of a provider API key flowing through the app, I had to make sure the Lambda execution role had the right Bedrock permissions and that the environment was using the right AWS identity.

The second surprise was request assumptions. Some of my tests had quietly encoded the behavior of the old provider. They were checking response formatting, token fields, or a specific error message that no longer matched Bedrock’s response shape exactly.

The third surprise was prompt framing. The prompt itself did not need a rewrite, but I did need to verify that the model behaved the same way with the new backend. Small differences in output style, truncation, or refusal behavior can show up even when the request format looks equivalent.

So I did what I usually do when a migration looks simple but not trivial: I added a few focused checks instead of trying to solve everything with one giant integration test.

The gotchas I would warn someone about

If you are planning a similar migration, these are the gotchas I would watch first:

  • Auth is not interchangeable. OpenAI-style request shapes do not mean the permission model is the same.
  • Model names still matter. Compatibility layers reduce rewrite work, but they do not eliminate provider-specific model mapping.
  • Retries should be intentional. Some failures are safe to retry. Some are configuration issues that should fail fast.
  • Token usage can shift. If you rely on hard token ceilings, re-check them after the provider swap.
  • Tests that overfit the old provider will lie to you. Update expectations, not just endpoints.

That last one was the most annoying. The more exact a test is about provider-specific wording, the more likely it is to become a migration tax later.

What I changed in the tests

I did not try to preserve the old tests exactly.

Instead, I rewrote the tests to validate what the app actually needed to guarantee:

  • the Lambda handler accepts a valid request
  • the client adapter sends the right prompt content
  • the response is normalized into my internal shape
  • failures are logged and surfaced properly
  • retry behavior only triggers on the right classes of errors

That made the tests less brittle and more useful for the new provider.

I also added one or two path-specific tests for the new Bedrock branch so I could prove the migration did not just compile. It had to run through the real call path at least once in a safe environment.

What improved after the migration

The best part of a migration like this is not the shiny new provider. It is the reduction in mental overhead.

After the switch, the app felt simpler to operate because the model access now lived more naturally inside the AWS stack. I had fewer places to think about credentials, fewer pieces of provider-specific glue, and a cleaner story for who can call what.

That did not magically solve every problem. It did not make prompts better by itself. It did not remove the need for observability. It did not eliminate model differences.

But it did make the app easier to own.

That matters. A lot of engineering work is not about finding the perfect architecture. It is about removing avoidable complexity from the parts you have to keep living with.

The rule I follow for migrations like this

My rule is simple: if the provider change can be contained behind one adapter, I will usually do it. If it forces the rest of the application to become provider-aware, I slow down and ask whether the move is still worth it.

That is the difference between a migration and a rewrite.

In this case, Bedrock’s compatibility layer gave me enough surface-area overlap to keep the app intact. I changed the client, tightened the tests, checked the auth model, and kept the rest of the system steady.

That is exactly how I like migrations to go: small enough to verify, boring enough to trust, and useful enough to keep.

If you are considering a similar move, my advice is to start by isolating the model client behind a clean interface. If you do that early, the rest of the migration gets a lot easier.

And if you can avoid rewriting everything while still improving the stack, that is usually the best outcome.

Get the next note

I email when a new post goes up. One send a week, and only if there's something new.

Want this applied on your account? Start with MLOps.

Related reading

More posts, sliding underneath the article

Kept below the post instead of in a sidebar, with a slow continuous motion for a cleaner editorial feel.

On this post

Comments

A reply stays under the note it answers.

0 comments

No comments yet.

If you have a note on How I Migrated a Lambda AI App to Bedrock’s OpenAI-Compatible APIs Without Rewriting Everything, sign in and leave it.