Run Claude anywhere
The same code runs on the direct Anthropic API, on Amazon Bedrock, and on Google Vertex — you swap the client and keep messages.create identical. Learn the three real SDK clients, the auth each one needs, how model-IDs differ per provider, when and why teams pick each door, and a config-driven factory that makes the provider a one-line setting.
Learning objectives
- Say what "run Claude anywhere" means: one
messages.createcall, three clients. - Construct each client —
Anthropic(),AnthropicBedrock(...),AnthropicVertex(...). - Match each provider to its auth:
ANTHROPIC_API_KEYvs AWS IAM vs GCP ADC. - Map a logical model name to the right model-ID string for each provider.
- Reason about feature availability and lag across providers before you commit.
- Write a config-driven
make_client(provider)factory so switching providers is one setting.
1 · One Claude, three front doors essential
You already know the everyday call: build a client, hand it a list of messages, read the reply. What most people don't realize is that the exact same call works whether Claude is served by Anthropic directly, by Amazon Bedrock inside your AWS account, or by Google Vertex AI inside your GCP project. Same model, same Messages API, same response shape — the only thing that changes is which client object you build.
The anthropic Python SDK ships three client classes for exactly this: Anthropic, AnthropicBedrock, and AnthropicVertex. All three are real, first-class classes; all three expose the identical client.messages.create(...) method. Pick the client that matches where you want the request billed and governed — and leave every line about the conversation untouched.
messages you send and the content you read back are not. Build the portability into the client factory and the rest of your app never learns which provider it's on.2 · The shape of portability essential
Picture your app calling one client factory. The factory reads a config value and returns the right client; from there a single messages.create lands on whichever provider you chose:
This picture is the whole chapter on one line: your application talks to one factory, and the factory — not your app — decides which of the three Claude providers the request goes to. Read it left to right.
- Your app — never names a provider. It just asks the factory for a client and then calls
messages.create. This is the code that stays the same no matter where Claude runs. - make_client() — the one function that knows about providers. It reads a single config value and hands back the matching client object.
- Anthropic API / Bedrock / Vertex — the three doors. The factory returns
Anthropic(),AnthropicBedrock(), orAnthropicVertex()depending on the config; behind all three sits the same Claude. - The arrows fan out from the factory because only one door is chosen per run — the config picks it, and everything after the factory is identical.
In short: Same brain, three lobbies. Whenever provider code feels tangled, come back to this shape: keep the provider choice inside the factory, and let the rest of the app stay provider-blind.
Read it left to right. Your app never names a provider — it calls make_client(). The factory looks at one config value and returns Anthropic(), AnthropicBedrock(...), or AnthropicVertex(...). All three converge on the same messages.create. The branch that picks a provider lives in one function; everything downstream is provider-agnostic.
3 · Recipe 1 — the direct Anthropic API essential
Start with the baseline everyone knows. The direct client reads ANTHROPIC_API_KEY from the environment, so the key never appears in code. Note the model-ID: on the direct API it's the plain, current alias claude-opus-4-8.
direct_api.py# needs: pip install anthropic
# Anthropic: ANTHROPIC_API_KEY in the environment; makes a real API call
from anthropic import Anthropic
client = Anthropic() # reads ANTHROPIC_API_KEY from the environment
resp = client.messages.create(
model="claude-opus-4-8", # direct API: plain model alias
max_tokens=300,
system="You are a concise SRE assistant.",
messages=[{"role": "user", "content": "In one sentence, what is an SLO?"}],
)
print(resp.content[0].text)
This is the baseline call — the ordinary first request to the direct Anthropic API. It needs pip install anthropic and an ANTHROPIC_API_KEY, and it makes a real network call. Hold its shape in mind: the next two recipes change only the first couple of lines.
Anthropic()builds the direct client. It reads your key from the environment, so the secret never appears in the code.- The model string is the plain alias for the current Opus model — no prefix, no region. On the direct API that's all you write.
messages.create(...)sends the request:max_tokenscaps the reply,systemsets the assistant's role,messagesis the conversation (one user question here).resp.content[0].textpulls the words out of the reply — the content is a list of blocks, so you grab the first block's text.
What the output means: Prints Claude's one-sentence answer describing an SLO. (Marked "needs API key + network" because it makes a live call.)
Try this: Copy these lines. In the next two recipes, watch how only the import, the client line, and the model string change — this whole request block is reused verbatim.
4 · Recipe 2 — Amazon Bedrock intermediate
AnthropicBedrock routes the same request through your AWS account. There is no API key — auth is your AWS credentials (an IAM role on Lambda/EC2/ECS, or your local ~/.aws profile), and you must have requested model access to Claude in the Bedrock console for your region. The model-ID takes an anthropic. prefix.
bedrock.py# needs: pip install "anthropic[bedrock]"
# Bedrock: AWS creds (IAM role / ~/.aws profile) + Bedrock model access; real API call
from anthropic import AnthropicBedrock
client = AnthropicBedrock(aws_region="us-east-1") # no API key — uses AWS creds
resp = client.messages.create(
model="anthropic.claude-opus-4-8", # Bedrock: anthropic. prefix on the id
max_tokens=300,
system="You are a concise SRE assistant.",
messages=[{"role": "user", "content": "In one sentence, what is an SLO?"}],
)
print(resp.content[0].text)
The same request, now routed through your AWS account with AnthropicBedrock. Compare it to Recipe 1 line by line: the import and the client changed, the model string gained a prefix, and nothing else did.
AnthropicBedrock(aws_region="us-east-1")builds the Bedrock client. There is no API key — it uses your AWS credentials (an IAM role on your compute, or your local AWS profile), so auth is AWS's job, not Anthropic's.- The model id carries an
anthropic.prefix — Bedrock's naming convention. A bare alias here would 404. - The
messages.create(...)block is byte-for-byte identical to the direct API recipe — same arguments, same reply handling. - Two AWS-side things must be true first: an IAM policy allowing
bedrock:InvokeModel, and model access enabled for Claude in the Bedrock console for that region.
What the output means: Prints the same one-sentence SLO answer — but billed and governed inside your AWS account. (Marked "needs API key + network": here that means valid AWS creds.)
Try this: Diff this file against direct_api.py. Only lines 3–4 (import + client) and the model string differ — proof that the conversation code is portable.
bedrock:InvokeModel. Second, you must enable model access for Claude in the Bedrock console for that aws_region — a fresh account has it off by default, and the request 400s until you do. Both are AWS-side setup, not SDK code.5 · Recipe 3 — Google Vertex AI intermediate
AnthropicVertex routes the request through your GCP project. Auth is Google Application Default Credentials (ADC) — run gcloud auth application-default login locally, or use the attached service account on GCP compute. You pass a project_id and a region. The model-ID for current-generation models is the bare alias (no prefix).
vertex.py# needs: pip install "anthropic[vertex]"
# Vertex: gcloud ADC + a GCP project with the Vertex AI API enabled; real API call
from anthropic import AnthropicVertex
client = AnthropicVertex(project_id="my-gcp-project", region="us-east5")
resp = client.messages.create(
model="claude-opus-4-8", # Vertex: bare id (dated snapshots use @date)
max_tokens=300,
system="You are a concise SRE assistant.",
messages=[{"role": "user", "content": "In one sentence, what is an SLO?"}],
)
print(resp.content[0].text)
The third door: AnthropicVertex routes the request through your Google Cloud project. Again, only the client and the model string are Vertex-specific — the request itself is the same one you've now written three times.
AnthropicVertex(project_id=..., region=...)builds the Vertex client. You pass a GCP project and region instead of a key; auth comes from Google Application Default Credentials (thegcloud auth application-default loginyou ran, or the compute's service account).- The model id is the bare alias — no
anthropic.prefix (that's a Bedrock thing). Older dated-snapshot models on Vertex use an@datesuffix, noted in the comment. - The
messages.create(...)block is, once more, identical to both earlier recipes.
What the output means: Prints the same SLO answer, served from your GCP project. (Marked "needs API key + network": here that means working gcloud ADC.)
Try this: Line the three recipes up in one editor. The messages.create block never changes — the model id and the client constructor are the only moving parts across providers.
Put the three recipes side by side and the pattern jumps out: lines 3–4 (the import and the client) and the model string are the only differences. The messages.create(...) block — max_tokens, system, messages — is copy-pasted verbatim across all three.
anthropic. prefix on Bedrock and the bare alias on Vertex trip people constantly. A Bedrock id sent to Vertex (or vice-versa) 404s. This is exactly the difference the offline mapper in section 7 exists to encapsulate.6 · Auth & model-IDs at a glance advanced
Everything that differs between the three doors fits in one table. Learn this and the rest is muscle memory:
| Direct API | Amazon Bedrock | Google Vertex | |
|---|---|---|---|
| Client | Anthropic() | AnthropicBedrock(aws_region=…) | AnthropicVertex(project_id=…, region=…) |
| Auth | ANTHROPIC_API_KEY | AWS creds / IAM role | GCP ADC (gcloud login) |
| Model-ID | claude-opus-4-8 | anthropic.claude-opus-4-8 | claude-opus-4-8 |
| Install extra | anthropic | anthropic[bedrock] | anthropic[vertex] |
| messages.create | identical | identical | identical |
The bottom row is the payoff. The request body — system prompt, messages, tools, streaming, prompt caching, structured output — is the same object on every provider. The top four rows are the only per-provider knobs, and a factory can set all of them from one config value.
ANTHROPIC_API_KEY). Bedrock and Vertex use the cloud's own identity system — IAM roles and GCP service accounts — so there's no Anthropic key to rotate or leak, and access is governed by the same policies as the rest of your cloud.7 · The mapper (this one runs offline) professional
Before wiring a live factory, encode the per-provider differences in a tiny, testable function. The helper below is pure stdlib and runs with a plain python file.py — no keys, no network. Given a logical model name and a provider, it returns the right client class name and the correctly-formatted model-ID, so the naming rules live in one auditable place.
pick_provider.pydef pick_provider(provider, logical_model="opus"):
"""Map (provider, logical_model) -> the client class name + provider-specific
model-id string. Pure stdlib: no SDK, no network. Encapsulates the naming rules
so no other code has to remember the anthropic. prefix or the bare-alias quirk."""
base = {"opus": "claude-opus-4-8", "haiku": "claude-haiku-4-5"}[logical_model]
table = {
# provider -> (client class, model-id transform)
"api": ("Anthropic", base),
"bedrock": ("AnthropicBedrock", "anthropic." + base), # Bedrock: prefix
"vertex": ("AnthropicVertex", base), # Vertex: bare alias
}
client_class, model_id = table[provider]
return {"client_class": client_class, "model_id": model_id}
for prov in ["api", "bedrock", "vertex"]:
r = pick_provider(prov, "opus")
print(f"{prov:8} {r['client_class']:18} model_id={r['model_id']}")
api Anthropic model_id=claude-opus-4-8
bedrock AnthropicBedrock model_id=anthropic.claude-opus-4-8
vertex AnthropicVertex model_id=claude-opus-4-8
Unlike the three recipes above, this helper is pure Python — no key, no network — so it runs with a plain python pick_provider.py. It captures the per-provider differences (client class + model-id format) in one small, testable function.
baselooks up the real model id for a logical name you choose ("opus"or"haiku") — so your app can say "opus" and let the mapper resolve the exact string.- The
tablemaps each provider to a pair: the client class name and the correctly-formatted model id. Only the Bedrock row adds theanthropic.prefix; the direct and Vertex rows keep the bare alias. - It returns a small dict —
client_classandmodel_id— so every call site asks the mapper instead of hard-coding a provider-specific string. - The loop prints one aligned row per provider using f-string width padding (
:8,:18) so the columns line up.
What the output means: Three aligned rows: api and vertex show the bare claude-opus-4-8, while bedrock shows anthropic.claude-opus-4-8 — exactly the console output shown below the code.
Try this: Add a line for "sonnet": "claude-sonnet-4-6" to base and call pick_provider("bedrock", "sonnet"). The prefix rule is applied for free, because it lives in this one function.
anthropic. prefix and the client-class choice are decided once. Every call site asks the mapper instead of hard-coding a string, so a new provider or a renamed model is a one-line change, not a repo-wide find-and-replace.8 · Why teams pick each door professional
Portability is only useful if you know why you'd move. The choice is almost never about the model — it's identical — and almost always about compliance, existing cloud commitments, and latency:
| Driver | Points you to | Because |
|---|---|---|
| Data residency / compliance | Bedrock or Vertex | Requests and data stay inside your AWS/GCP account and its region, under your existing audit and governance controls. |
| Existing cloud commitment | the cloud you're already on | Claude spend rolls onto one bill and one committed-use / EDP discount instead of a separate Anthropic invoice. |
| Identity & access | Bedrock or Vertex | Access is an IAM policy or GCP role — same model your security team already manages — with no extra API key to rotate. |
| Latency / region | whichever is closest | Serving from a region near your workload cuts round-trip time; pick the provider with a Claude region where you run. |
| Simplicity / newest features | the direct API | The direct API gets the fullest, earliest feature set (see the lag note below) with the least setup — just a key. |
A useful rule of thumb: if you already live in AWS or GCP, use the matching door so AI spend, identity, and data stay in the cloud you already trust and audit. If you have no such constraint, the direct API is the simplest path and the first to get new capabilities.
9 · Feature availability & lag advanced
The models are the same, but not every API feature ships on every provider at the same time. New capabilities land on the direct Anthropic API first and reach the cloud providers on their own schedule. A few concrete gaps worth knowing before you commit a workload:
| Feature | Direct API | Bedrock | Vertex |
|---|---|---|---|
| Messages, streaming, tool use | yes | yes | yes |
| Prompt caching | yes | yes | yes |
| Message Batches API | yes | no | no |
| Files API | yes | no | no |
| Web search (server tool) | yes | no | yes (basic only) |
| Web fetch / code execution | yes | no | no |
The core — messages, streaming, tools, caching, thinking — is everywhere, so portability is real for the vast majority of apps. The gaps are in the newer, peripheral surfaces (Batches, Files, some server-side tools). Check availability for the specific features your workload needs before you assume a provider is a drop-in swap.
messages.create with tools and caching, it is fully portable today. If it depends on the Batches API or the Files API, it is not portable to Bedrock or Vertex — those endpoints simply aren't there. Know which bucket you're in.10 · Tech-lead — the provider-agnostic wrapper tech-lead
A lead's job is to make provider choice a configuration decision, not a code decision. The pattern: one factory that turns a config value into the right client, so business logic calls messages.create and never learns which door it went through. Here is the shape — the client construction is real SDK; treat it as the template you'd wire into a real app.
make_client.py# needs: pip install "anthropic[bedrock,vertex]"
# Anthropic: ANTHROPIC_API_KEY | Bedrock: AWS creds + model access | Vertex: gcloud ADC
import os
from anthropic import Anthropic, AnthropicBedrock, AnthropicVertex
def make_client(provider):
"""Return the right client for the configured provider. The ONLY provider-aware
code in the app — everything downstream calls client.messages.create unchanged."""
if provider == "api":
return Anthropic() # ANTHROPIC_API_KEY
if provider == "bedrock":
return AnthropicBedrock(aws_region=os.environ["AWS_REGION"])
if provider == "vertex":
return AnthropicVertex(project_id=os.environ["GCP_PROJECT"],
region=os.environ["GCP_REGION"])
raise ValueError(f"unknown provider: {provider}")
# ONE config value decides the provider; the model-id comes from the offline mapper.
provider = os.environ.get("LLM_PROVIDER", "api")
client = make_client(provider)
model_id = pick_provider(provider, "opus")["model_id"] # from section 7
resp = client.messages.create( # <-- identical on every provider
model=model_id,
max_tokens=300,
system="You are a concise SRE assistant.",
messages=[{"role": "user", "content": "In one sentence, what is an SLO?"}],
)
print(resp.content[0].text)
This is the pattern a tech-lead ships: one factory turns a config value into the right client, so the rest of the app never learns which provider it's on. The client construction is the real SDK; treat it as the template for a real integration.
make_client(provider)is the only provider-aware function. Each branch returns the matching real client —Anthropic(),AnthropicBedrock(...), orAnthropicVertex(...)— reading its credentials from the environment.- One config value —
LLM_PROVIDER— decides the provider. The model id comes from the offline mapper (section 7), so naming rules stay centralized too. - The
messages.create(...)call is provider-blind: it takes the resolvedmodel_idand the same messages, and works on whichever client the factory returned. - To move a workload from the direct API to Bedrock — say, for a compliance review — you change one environment variable and ship. No business logic, no prompt changes.
What the output means: Prints the SLO answer from whichever provider LLM_PROVIDER selected — the app code is identical across all three.
Try this: Set LLM_PROVIDER=vertex then =bedrock and re-run (with the right creds). Only the environment variable changed — that's the whole point of the factory.
Notice how the provider-aware surface has shrunk to two functions: make_client (which client) and pick_provider (which model-ID). The messages.create call is provider-blind. To move a workload from the direct API to Bedrock for a compliance review, you change one environment variable — LLM_PROVIDER=bedrock — and ship. No business logic changes, no prompt changes, no rewrite.
Tech-lead checklist for a portable Claude integration
- One factory, one config value. All provider selection lives in
make_client(provider), driven by a single env var / config key. No other module importsAnthropicBedrockorAnthropicVertex. - Centralize model-IDs. Never hard-code
anthropic.claude-…at a call site — go through the mapper so the prefix rule and alias choices live in one place. - Feature-gate on availability. If a code path uses Batches or Files, it isn't portable to Bedrock/Vertex — guard it and fail loudly rather than 404 at runtime.
- Auth per environment, not in code. Direct = key from env; Bedrock = IAM role on the compute; Vertex = ADC / service account. Keep secrets out of the codebase on every door.
- Test the swap. Run your smoke test against each provider you support in CI (or a scheduled job) so a provider-specific regression is caught before a customer hits it.
Exercise AP6.1 — Port a call and abstract it
Context: The whole run-anywhere promise is that your first-call code moves to a second cloud untouched — only the constructor and model id change — and a small factory collapses even that into a single config value.
Your task: Take your first-call code, get it running unchanged on a second provider by swapping Anthropic() for AnthropicBedrock(aws_region=...) (or AnthropicVertex(project_id=..., region=...)) and changing only the model id, then fold both into a make_client(provider) factory.
Requirements:
- Run the same
messages.createbody on a second provider, changing only the client and model id - Add the
anthropic.model-id prefix for Bedrock only - Build a
make_client(provider)factory that returns the right client per provider - Show the same app code runs on either door by changing one config value
- The live call needs creds; the factory routing runs offline
💡 Hint: Once the factory returns a ready client, downstream code should never mention a provider again — that's the signal you've abstracted it correctly.
Exercise AP6.2 — Extend the mapper
Context: A portability mapper is only useful if it stays correct as you add models — extending it and re-checking that Bedrock (and only Bedrock) gains the prefix is how you keep that one sharp edge from leaking into app code.
Your task: Add a third logical model (e.g. "sonnet" → "claude-sonnet-4-6") to pick_provider, re-run it for all three providers, confirm the Bedrock id gains the anthropic. prefix while direct and Vertex stay bare, and name one feature that would block a move to Bedrock or Vertex.
Requirements:
- Add the new logical-model mapping and re-run
pick_provideracross direct/bedrock/vertex - Confirm only the Bedrock id carries the
anthropic.prefix - Verify the direct and Vertex ids remain bare
- Name one feature your app depends on (e.g. Batches or Files API) that isn't on Bedrock/Vertex
- Run entirely offline — no keys
💡 Hint: Reuse the availability table from the ladder's industry rung to answer the blocking-feature question — Batches and Files are the usual direct-only culprits.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: The direct Anthropic API is the baseline every portability story starts from: the client reads your key from the environment, and everything after the constructor is identical no matter which cloud eventually hosts the call.
Your task: Write the minimal call that sends one user message to Claude via the direct Anthropic API and prints the reply text.
Requirements:
- Construct
anthropic.Anthropic(), which readsANTHROPIC_API_KEYfrom the environment - Call
client.messages.createwith amodel,max_tokens, and a single user message - Print the reply via
resp.content[0].text - Requires an API key to run
💡 Hint: Don't pass the key explicitly — the constructor picks it up from the environment, which is what makes the rest of the code provider-agnostic.
Show solution
The direct client reads ANTHROPIC_API_KEY from the environment. Everything after the client construction is identical across providers.
import anthropic
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY
resp = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=256,
messages=[{"role": "user", "content": "Say hello in one short sentence."}],
)
print(resp.content[0].text)
Context: The portability payoff is concrete: moving a working call from the direct API to Amazon Bedrock changes only the client constructor and the model-id prefix — the messages.create body is byte-for-byte identical.
Your task: Take the beginner call and make it run on Amazon Bedrock instead, changing only the client and the model id, and note what stays the same.
Requirements:
- Swap
anthropic.Anthropic()forAnthropicBedrock(aws_region=...), which uses IAM creds - Prefix the model id with
anthropic.(e.g.anthropic.claude-sonnet-4-6) — Bedrock requires it - Leave the
messages.createarguments unchanged - Call out that only the constructor and the id prefix differ
- Requires AWS creds to run
💡 Hint: The anthropic. prefix is the one sharp edge — Bedrock is the only door that needs it; direct and Vertex ids stay bare.
Show solution
Only the constructor and the model-id prefix change; messages.create() is byte-for-byte identical. Bedrock requires the anthropic. prefix on the id.
from anthropic import AnthropicBedrock
client = AnthropicBedrock(aws_region="us-east-1") # uses IAM creds
resp = client.messages.create(
model="anthropic.claude-sonnet-4-6", # note the anthropic. prefix
max_tokens=256,
messages=[{"role": "user", "content": "Say hello in one short sentence."}],
)
print(resp.content[0].text)
Context: Before you can build a factory you need the pure mapping it hides: which client class and which model-id format each provider wants. Capturing this in one offline lookup isolates the single sharp edge (Bedrock's prefix) so nothing else has to know about it.
Your task: Implement pick_provider(provider, logical_model) that returns the client class name and correctly-formatted model id for direct, bedrock, and vertex.
Requirements:
- Return
("Anthropic", model)fordirect - Return
("AnthropicBedrock", "anthropic." + model)forbedrock - Return
("AnthropicVertex", model)forvertex - Only Bedrock gets the
anthropic.prefix - Raise on an unknown provider; run entirely offline with no keys
💡 Hint: A single dict keyed by provider name is the whole mapper — put the id formatting inside the table value so callers never special-case Bedrock.
Show solution
This is the lesson's mapper: a pure-stdlib lookup that hides the one sharp edge (Bedrock's id prefix). Runnable with no keys.
def pick_provider(provider, logical_model):
table = {
"direct": ("Anthropic", logical_model),
"bedrock": ("AnthropicBedrock", f"anthropic.{logical_model}"),
"vertex": ("AnthropicVertex", logical_model),
}
if provider not in table:
raise ValueError(f"unknown provider: {provider}")
return table[provider]
for p in ("direct", "bedrock", "vertex"):
print(p, pick_provider(p, "claude-sonnet-4-6"))
# bedrock -> ('AnthropicBedrock', 'anthropic.claude-sonnet-4-6')
Context: The factory pattern lets business code call client.messages.create(...) and never name a provider — but the routing logic still has to be testable in CI where no SDK or cloud creds exist, so construction must stay lazy.
Your task: Wrap the mapper in make_client(provider, **kw) that returns a ready client, and keep the routing logic runnable offline without SDKs installed.
Requirements:
make_clientreturns the right client (Anthropic/AnthropicBedrock/AnthropicVertex) per provider- Import
anthropiclazily inside the function so the module loads without the SDK - Expose a pure
model_id(provider, logical)that adds theanthropic.prefix only for Bedrock - Raise
ValueErroron an unknown provider - Include an offline assertion that the id routing is correct without building a client
💡 Hint: Split the two concerns: id formatting is pure and always testable; client construction is the only part that needs the SDK, so defer that import.
Show solution
The factory centralizes the branching. Import is lazy so the routing logic is testable offline.
def make_client(provider, **kw):
import anthropic
if provider == "direct":
return anthropic.Anthropic(**kw)
if provider == "bedrock":
return anthropic.AnthropicBedrock(**kw)
if provider == "vertex":
return anthropic.AnthropicVertex(**kw)
raise ValueError(provider)
def model_id(provider, logical):
return f"anthropic.{logical}" if provider == "bedrock" else logical
# offline sanity check of the routing (no SDK needed):
assert model_id("bedrock", "claude-sonnet-4-6") == "anthropic.claude-sonnet-4-6"
assert model_id("direct", "claude-sonnet-4-6") == "claude-sonnet-4-6"
print("routing ok")
Context: In real deployments the provider is an operational decision, not a code decision — reading it from a single env var with a safe default lets you re-route the whole app by flipping a flag, with one call site that never learns which cloud it's on.
Your task: Read the provider from LLM_PROVIDER with a safe default, resolve the model id, and keep a single provider-blind call site, then show that flipping the env var re-routes without touching business logic.
Requirements:
- Read
os.environ.get("LLM_PROVIDER", "direct")so the default is safe - Resolve the id with a helper that adds the
anthropic.prefix only for Bedrock - The business function (e.g.
ask) names no provider — it just callsmessages.create - Demonstrate that changing the env value re-routes the model id with no code change
- Run offline — routing only, no live call needed
💡 Hint: Keep exactly one place that reads the env var and one that formats the id; everything downstream should take a client and a resolved model, staying blind to the provider.
Show solution
One config value decides the door; the rest of the app stays provider-blind.
import os
PROVIDER = os.environ.get("LLM_PROVIDER", "direct")
LOGICAL = "claude-sonnet-4-6"
def model_id(provider, logical):
return f"anthropic.{logical}" if provider == "bedrock" else logical
def ask(client, prompt): # provider-blind
return client.messages.create(
model=model_id(PROVIDER, LOGICAL),
max_tokens=256,
messages=[{"role": "user", "content": prompt}],
)
for env in ("direct", "bedrock", "vertex"): # simulate the flag flip
print(env, "->", model_id(env, LOGICAL))
Context: Portability isn't a property of the SDK — it's a property of the feature set. A contract that forces a workload onto Bedrock can silently break code paths that rely on the Batches or Files APIs, which aren't available there, so a cheap preflight beats a broken cutover.
Your task: Write an offline preflight that, given a target provider and the features a job needs, returns whether the job can move and lists any blockers.
Requirements:
- Model a feature → supported-providers table (e.g. messages/streaming/tools everywhere, batches/files direct-only)
- Compute blockers as the needed features the target provider doesn't support
- Return a result with
can_move, thetarget, and theblockerslist - A Bedrock job needing
batchesreportscan_move=Falsewithbatchesas a blocker - Run entirely offline — pure lookup, no keys
💡 Hint: Invert the question: for each needed feature, ask whether the target is in that feature's supported set — the ones that fail are exactly your blockers.
Show solution
Portability is a property of the feature set, not the SDK. A cheap preflight prevents a broken cutover.
FEATURES = { # feature -> providers that support it
"messages": {"direct", "bedrock", "vertex"},
"streaming": {"direct", "bedrock", "vertex"},
"tools": {"direct", "bedrock", "vertex"},
"batches": {"direct"}, # not on Bedrock/Vertex
"files": {"direct"},
}
def preflight(target, needed):
blockers = [f for f in needed if target not in FEATURES.get(f, set())]
return {"can_move": not blockers, "target": target, "blockers": blockers}
job = ["messages", "streaming", "batches"]
r = preflight("bedrock", job)
print(r) # can_move False, blockers ['batches']
if not r["can_move"]:
print("Refactor these off before cutover:", r["blockers"])
✓ Checkpoint — you can move on when you can…
- Explain "run Claude anywhere": one
messages.create, three interchangeable clients. - Construct
Anthropic(),AnthropicBedrock(...), andAnthropicVertex(...). - Match each provider to its auth:
ANTHROPIC_API_KEY/ AWS IAM / GCP ADC. - Map a logical model name to the right id (bare vs
anthropic.prefix) per provider. - Say which features (Batches, Files, some server tools) are NOT portable to the clouds.
- Write a config-driven
make_clientfactory so provider choice is one setting.
Knowledge check check yourself
What is the model-ID naming quirk across the direct API, Bedrock, and Vertex, and why is it the top portability footgun?
Show answer
anthropic. prefix (e.g. anthropic.claude-opus-4-8) while the direct API and Vertex use the bare alias (claude-opus-4-8). Sending a Bedrock ID to Vertex (or vice versa) 404s, so centralizing IDs in one mapper is what prevents that mistake when swapping providers.Why is portability a property of your feature set rather than of the SDK, and which features break a drop-in provider swap?