DEV Community

Maruchin Tech
Maruchin Tech

Posted on

Swap Your LLM Without a Deploy: Dynamic Model Routing on Bedrock with AWS AppConfig

Bedrock model IDs have a short shelf life. New Claude, Nova, and Llama versions land every few months, pricing changes, and yesterday's best pick becomes today's legacy model. If the model ID is hardcoded in your Lambda, every swap is a code change, a review, and a redeploy — and if the new model misbehaves, rolling back is another deploy.

In this hands-on, we'll fix that by building a model router where the model choice lives in AWS AppConfig feature flags, not in code. A Lambda behind API Gateway reads the flags at runtime and routes each request to Claude Haiku, Amazon Nova Micro, or Meta Llama. Swapping a model becomes a config deployment: no code change, no redeploy, instant rollback.

Prefer video? This entire hands-on is also on YouTube:

What we'll build

curl ?model=fast ──▶ API Gateway ──▶ Lambda ──▶ Amazon Bedrock (Converse)
                                      │              ▲
                                      │   which model_id?
                                      ▼              │
                               AWS AppConfig ────────┘
                               feature flags:
                                 fast  → Claude Haiku
                                 cheap → Nova Micro
                                 open  → Llama
Enter fullscreen mode Exit fullscreen mode

The client asks for a routing key (fast, cheap, open) — never a model ID. What each key means is decided by whoever controls the AppConfig deployment.

Why AppConfig?

AWS AppConfig is a managed feature-flag and configuration service. Three properties make it a good fit for LLM routing:

  • Config is deployed, not just saved. Changes go out through deployment strategies — all-at-once for dev, gradual rollouts with automatic rollback for production. A bad model swap can be rolled back the same way it went out.
  • Versioned history. Every flag set is a numbered version; you can see exactly which model was live when.
  • Runtime retrieval with sessions. Your code polls for the latest configuration and only receives content when something changed — cheap to call from a warm Lambda.

Prerequisites

  • AWS CLI v2 configured for us-east-1
  • Bedrock model access enabled for the Claude, Nova, and Llama models you plan to use
  • jq installed
  • A Lambda + API Gateway (HTTP API) you can create in the console

Step 1: Pick your models

Bedrock models are updated frequently — list what's currently available and use the latest versions, not the ones printed in this article.

# Anthropic Haiku family
aws bedrock list-inference-profiles \
  --region us-east-1 \
  --query 'inferenceProfileSummaries[?contains(inferenceProfileId, `haiku`)].inferenceProfileId' \
  --output table

# Amazon Nova Micro
aws bedrock list-inference-profiles \
  --region us-east-1 \
  --query 'inferenceProfileSummaries[?contains(inferenceProfileId, `nova-micro`)].inferenceProfileId' \
  --output table

# Meta Llama
aws bedrock list-inference-profiles \
  --region us-east-1 \
  --query 'inferenceProfileSummaries[?contains(inferenceProfileId, `llama`)].inferenceProfileId' \
  --output table
Enter fullscreen mode Exit fullscreen mode

Export the ones you'll route between (replace with the versions listed in your account):

export CLAUDE_MODEL="us.anthropic.claude-haiku-4-5-20251001-v1:0"
export NOVA_MODEL="us.amazon.nova-micro-v1:0"
export LLAMA_MODEL="us.meta.llama4-scout-17b-instruct-v1:0"

echo "Claude: $CLAUDE_MODEL"
echo "Nova  : $NOVA_MODEL"
echo "Llama : $LLAMA_MODEL"
Enter fullscreen mode Exit fullscreen mode

Step 2: Create and deploy the AppConfig flags

AppConfig has a small hierarchy: an application contains environments (dev, prod, …) and configuration profiles (the config itself). We create one of each, then a feature-flag document with three flags — each carrying a model_id attribute.

REGION=us-east-1

# 1. Application
APP_ID=$(aws appconfig create-application --region $REGION \
  --name bedrock-router --query Id --output text)

# 2. Environment
ENV_ID=$(aws appconfig create-environment --region $REGION \
  --application-id $APP_ID --name dev --query Id --output text)

# 3. Configuration Profile (feature flag type)
PROFILE_ID=$(aws appconfig create-configuration-profile --region $REGION \
  --application-id $APP_ID --name model-router \
  --location-uri hosted --type "AWS.AppConfig.FeatureFlags" \
  --query Id --output text)

# 4. Feature flags
jq -n \
  --arg c "$CLAUDE_MODEL" --arg n "$NOVA_MODEL" --arg l "$LLAMA_MODEL" \
'{
  flags: {
    fast:  {name:"fast",  attributes:{model_id:{constraints:{type:"string"}}}},
    cheap: {name:"cheap", attributes:{model_id:{constraints:{type:"string"}}}},
    open:  {name:"open",  attributes:{model_id:{constraints:{type:"string"}}}}
  },
  values: {
    fast:  {enabled:true, model_id:$c},
    cheap: {enabled:true, model_id:$n},
    open:  {enabled:true, model_id:$l}
  },
  version: "1"
}' > /tmp/flags.json

aws appconfig create-hosted-configuration-version --region $REGION \
  --application-id $APP_ID --configuration-profile-id $PROFILE_ID \
  --content-type "application/json" \
  --content fileb:///tmp/flags.json \
  /dev/null

# 5. Deploy (using the AWS predefined strategy AppConfig.AllAtOnce)
aws appconfig start-deployment --region $REGION \
  --application-id $APP_ID --environment-id $ENV_ID \
  --deployment-strategy-id AppConfig.AllAtOnce \
  --configuration-profile-id $PROFILE_ID \
  --configuration-version 1

echo "APP_ID=$APP_ID"
echo "ENV_ID=$ENV_ID"
echo "PROFILE_ID=$PROFILE_ID"
Enter fullscreen mode Exit fullscreen mode

AppConfig.AllAtOnce is fine for a dev environment. In production you'd pick a gradual strategy (linear or canary) with a CloudWatch alarm attached, so a bad config rolls back automatically.

Verify what's deployed:

aws appconfig get-hosted-configuration-version --region $REGION \
  --application-id $APP_ID --configuration-profile-id $PROFILE_ID \
  --version-number 1 \
  /tmp/flags_out.json > /dev/null

cat /tmp/flags_out.json | jq
Enter fullscreen mode Exit fullscreen mode

Step 3: Lambda execution role

Create a Lambda (Python, name it bedrock-router) in the console, then add this inline policy to its execution role (IAM → the role → Add permissions → Create inline policy → JSON):

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "BedrockConverse",
      "Effect": "Allow",
      "Action": [
        "bedrock:InvokeModel",
        "bedrock:InvokeModelWithResponseStream"
      ],
      "Resource": "*"
    },
    {
      "Sid": "AppConfigRead",
      "Effect": "Allow",
      "Action": [
        "appconfig:StartConfigurationSession",
        "appconfig:GetLatestConfiguration"
      ],
      "Resource": "*"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

(For production, scope Resource down to your specific models and AppConfig ARNs.)

Step 4: The Lambda function

import json
import os
import time
import boto3

REGION = os.environ["AWS_REGION"]
APP_ID = os.environ["APPCONFIG_APP_ID"]
ENV_ID = os.environ["APPCONFIG_ENV_ID"]
PROFILE_ID = os.environ["APPCONFIG_PROFILE_ID"]

appconfigdata = boto3.client("appconfigdata", region_name=REGION)
bedrock = boto3.client("bedrock-runtime", region_name=REGION)

# Simple cache to reduce AppConfig calls when the container is reused
_cache = {"config": None, "token": None, "expires_at": 0}
CACHE_TTL_SEC = 30


def _load_config():
    now = time.time()

    # Return the cached config while it is still valid
    if _cache["config"] is not None and now < _cache["expires_at"]:
        return _cache["config"]

    # Start a session only on the first call
    if _cache["token"] is None:
        session = appconfigdata.start_configuration_session(
            ApplicationIdentifier=APP_ID,
            EnvironmentIdentifier=ENV_ID,
            ConfigurationProfileIdentifier=PROFILE_ID,
        )
        _cache["token"] = session["InitialConfigurationToken"]

    resp = appconfigdata.get_latest_configuration(
        ConfigurationToken=_cache["token"]
    )
    _cache["token"] = resp["NextPollConfigurationToken"]

    content = resp["Configuration"].read()
    if content:
        # Replace only when there is an update. Keep the current cache if the content is empty
        _cache["config"] = json.loads(content)

    _cache["expires_at"] = now + CACHE_TTL_SEC
    return _cache["config"]


def lambda_handler(event, context):
    try:
        flags = _load_config()  # Feature flag value map: {"fast":{"enabled":true,"model_id":"..."}, ...}

        qs = event.get("queryStringParameters") or {}
        model_key = qs.get("model", "fast")  # Default is fast
        prompt = qs.get("prompt", "Hello. Please introduce yourself in one sentence.")

        flag = flags.get(model_key)
        if not flag or not flag.get("enabled"):
            return {
                "statusCode": 400,
                "headers": {"Content-Type": "application/json; charset=utf-8"},
                "body": json.dumps(
                    {
                        "error": f"model key not available: {model_key}",
                        "available_keys": [k for k, v in flags.items() if v.get("enabled")],
                    },
                    ensure_ascii=False,
                ),
            }

        model_id = flag["model_id"]

        resp = bedrock.converse(
            modelId=model_id,
            messages=[{"role": "user", "content": [{"text": prompt}]}],
            inferenceConfig={"maxTokens": 300, "temperature": 0.5},
        )

        text = resp["output"]["message"]["content"][0]["text"]

        return {
            "statusCode": 200,
            "headers": {"Content-Type": "application/json; charset=utf-8"},
            "body": json.dumps(
                {
                    "model_key": model_key,
                    "model_id": model_id,
                    "prompt": prompt,
                    "response": text,
                    "usage": resp.get("usage", {}),
                },
                ensure_ascii=False,
            ),
        }

    except Exception as e:
        return {
            "statusCode": 500,
            "headers": {"Content-Type": "application/json; charset=utf-8"},
            "body": json.dumps(
                {"error": type(e).__name__, "message": str(e)},
                ensure_ascii=False,
            ),
        }
Enter fullscreen mode Exit fullscreen mode

Three details worth reading twice:

  • The session/token dance. appconfigdata works as a polling session: start_configuration_session once, then get_latest_configuration with a token that gets replaced on every call. If nothing changed since the last poll, the response body is empty — that's why the code only overwrites the cache if content:.
  • The 30-second cache. Warm Lambda containers keep module-level state, so _cache survives between invocations. You get near-instant responses and at most one AppConfig poll per 30 seconds per container.
  • Unknown keys fail loudly. A key that doesn't exist (or is disabled) returns 400 with the list of available keys, instead of silently falling back to an expensive model.

Set the Lambda's environment variables (Configuration tab → Environment variables → Edit) with the values printed in Step 2:

echo "APPCONFIG_APP_ID    = $APP_ID"
echo "APPCONFIG_ENV_ID    = $ENV_ID"
echo "APPCONFIG_PROFILE_ID= $PROFILE_ID"
Enter fullscreen mode Exit fullscreen mode

Quick unit test (Test tab → Event name: test1 → Event JSON):

{
  "queryStringParameters": {
    "model": "cheap",
    "prompt": "Please introduce yourself in three lines"
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Put API Gateway in front and test

Create an HTTP API (name it bedrock-router-api) with a /chat route integrated with the Lambda, then:

# Change the URL below to match your environment
export API_URL="https://abc123xyz.execute-api.us-east-1.amazonaws.com"
echo "$API_URL/chat"
Enter fullscreen mode Exit fullscreen mode

Route to each model by key:

# Call Claude Haiku (fast)
curl -s -G "$API_URL/chat" -d "model=fast" --data-urlencode "prompt=What is generative AI, in three lines" | jq

# Call Nova Micro (cheap)
curl -s -G "$API_URL/chat" -d "model=cheap" --data-urlencode "prompt=What is generative AI, in three lines" | jq

# Call Llama (open)
curl -s -G "$API_URL/chat" -d "model=open" --data-urlencode "prompt=What is generative AI, in three lines" | jq

# If no key is specified, the default (fast) is used
curl -s -G "$API_URL/chat" --data-urlencode "prompt=Hello" | jq '.model_key, .model_id'

# Check error handling for an invalid key
curl -s -G "$API_URL/chat" -d "model=unknown" --data-urlencode "prompt=test" | jq
Enter fullscreen mode Exit fullscreen mode

Same endpoint, three different models, chosen by a query parameter.

Step 6: Swap a model with zero deploys

Now the payoff. Suppose Claude Haiku is overkill for the fast route and you want Nova Micro there too. Create version 2 of the flags — note fast now carries $n — and deploy it:

REGION=us-east-1

jq -n \
  --arg c "$CLAUDE_MODEL" --arg n "$NOVA_MODEL" --arg l "$LLAMA_MODEL" \
'{
  flags: {
    fast:  {name:"fast",  attributes:{model_id:{constraints:{type:"string"}}}},
    cheap: {name:"cheap", attributes:{model_id:{constraints:{type:"string"}}}},
    open:  {name:"open",  attributes:{model_id:{constraints:{type:"string"}}}}
  },
  values: {
    fast:  {enabled:true, model_id:$n},
    cheap: {enabled:true, model_id:$n},
    open:  {enabled:true, model_id:$l}
  },
  version: "1"
}' > /tmp/flags_v2.json

aws appconfig create-hosted-configuration-version --region $REGION \
  --application-id $APP_ID --configuration-profile-id $PROFILE_ID \
  --content-type "application/json" \
  --content fileb:///tmp/flags_v2.json \
  /dev/null

aws appconfig start-deployment --region $REGION \
  --application-id $APP_ID --environment-id $ENV_ID \
  --deployment-strategy-id AppConfig.AllAtOnce \
  --configuration-profile-id $PROFILE_ID \
  --configuration-version 2
Enter fullscreen mode Exit fullscreen mode

Wait for the cache TTL (up to ~30 seconds), then:

# fast should now be Nova Micro
curl -s -G "$API_URL/chat" -d "model=fast" --data-urlencode "prompt=Introduce yourself" | jq '.model_key, .model_id'
Enter fullscreen mode Exit fullscreen mode

The Lambda never changed. No deploy, no cold start, no release process — the model behind fast is now a different one, and deploying version 1 again would roll it back just as fast.

Cleanup

# API Gateway (look up the API ID and delete)
API_ID=$(aws apigatewayv2 get-apis --region us-east-1 \
  --query 'Items[?Name==`bedrock-router-api`].ApiId' --output text 2>/dev/null)
if [ -n "$API_ID" ] && [ "$API_ID" != "None" ]; then
  aws apigatewayv2 delete-api --region us-east-1 --api-id "$API_ID"
fi

# Lambda
aws lambda delete-function --region us-east-1 --function-name bedrock-router 2>/dev/null

# IAM role (inline policy first, then the role itself)
aws iam delete-role-policy \
  --role-name bedrock-router-role \
  --policy-name bedrock-router-inline 2>/dev/null
aws iam detach-role-policy \
  --role-name bedrock-router-role \
  --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole 2>/dev/null
aws iam delete-role --role-name bedrock-router-role 2>/dev/null

# AppConfig (child resources first)
if [ -n "$APP_ID" ]; then
  aws appconfig delete-environment --region us-east-1 \
    --application-id "$APP_ID" --environment-id "$ENV_ID" 2>/dev/null
  aws appconfig delete-configuration-profile --region us-east-1 \
    --application-id "$APP_ID" --configuration-profile-id "$PROFILE_ID" 2>/dev/null
  aws appconfig delete-application --region us-east-1 --application-id "$APP_ID" 2>/dev/null
fi
Enter fullscreen mode Exit fullscreen mode

Wrapping up

Separating "which model" from "the code that calls it" is not a convenience — it's how you keep an LLM application operable in a market where models are replaced every few months. AppConfig gives that separation deployment strategies, version history, and rollback for free. If your Bedrock model IDs live in code today, I recommend trying this pattern in your own environment.


About the author

Maruchin Tech — 12x AWS Certified | Cloud & AI for manufacturing and supply chain (AWS / Google Cloud / Azure) | Udemy instructor (100K+ students)

🎥 Video version of this hands-on:
https://youtu.be/6k2lO4_fA7o

📚 Full course — AWS Certified Generative AI Developer Professional (AIP-C01) Exam Prep:
https://www.udemy.com/course/aws-certified-generative-ai-developer-professional-exam-prep/

👨‍🏫 All my courses (Udemy profile):
https://www.udemy.com/user/maruchin-tech-2/

🎫 Monthly discount coupons:
https://www.youtube.com/@MaruchinTech-cloud/posts

Top comments (0)