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
Here's a moment that catches teams off guard. You've shipped a Claude feature against the direct Anthropic API and it works beautifully. Six months later your company signs an enterprise AWS deal, and the security team says: "AI traffic has to stay inside our AWS account, under our audit controls." Your stomach drops — do you rewrite everything? The happy answer is almost nothing changes. The same Claude you've been calling is also served through Amazon Bedrock, and moving to it touches two lines of your code. This chapter is about why that's true and how to build so it stays true.
Think of Claude as a person who works in one building but has three separate lobbies you can walk in through — a public front entrance (the direct API), a door that opens into an AWS-controlled corridor (Bedrock), and one into a Google-controlled corridor (Vertex). Whichever lobby you use, you reach the same person and have the same conversation. What changes is the security desk you pass on the way in — who checks your badge, who logs your visit, whose building rules apply. The model is the person; the provider is just the lobby.
The common mistake is to treat these as three different products that each need their own integration. They don't. Bedrock and Vertex are access routes to Claude, not different models — the weights, the behaviour, the response format are identical. So the work of "supporting three providers" is not three times the work; it's one integration plus a thin layer that picks the door. Getting that mental model right up front is what saves you from the panicked rewrite, and it's why portability is worth designing for on day one rather than bolting on under deadline pressure.
Read it top to bottom: three entrances across the top, all funneling down to the one box at the bottom.
- The three coloured boxes are the front doors — the direct Anthropic API, Amazon Bedrock (inside AWS), and Google Vertex (inside GCP). Each has its own "security desk": who checks your badge and whose rules apply.
- The three arrows all point at the same target, showing that the door you pick doesn't change where you end up.
- The bottom box is the payoff: the identical Claude — same weights, same
messages.create— no matter which lobby you walked in through.
In short: Supporting three providers isn't triple the work — it's one integration plus a thin layer that picks the door.
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
Portability isn't something you get for free — it's something you protect. The SDK makes the three clients interchangeable, but that only helps if the rest of your code stays ignorant of which one is in use. Scatter AnthropicBedrock imports and anthropic.claude-… strings across twenty files and you've quietly welded your app to one provider; the swap that could have been two lines becomes a twenty-file archaeology dig. So the real lesson of this chapter is less about the SDK and more about where you allow provider knowledge to live.
The analogy is a power adapter for international travel. Your laptop doesn't care which country it's in — it just wants clean power. The messy part (different plug shapes, different voltages) is handled by one adapter you carry, not by rewiring the laptop in every hotel. In software terms, that adapter is a client factory: a single function that absorbs all the per-provider ugliness so everything plugged into it can stay blissfully unaware. Your business logic is the laptop; the factory is the adapter; the three providers are the three countries' outlets.
Why does putting the branch in one function matter so much? Because the number of places that know about providers is the number of places that can break when a provider changes a naming rule, a region, or an auth mechanism. Keep it at one and a change is one edit; let it sprawl and every new provider becomes a hunt-and-patch across the codebase. The mistake beginners make is thinking "I'll add the abstraction later, once I actually need a second provider" — but by then the provider-specific strings have already leaked everywhere, and untangling them is precisely the work you were trying to avoid. Build the funnel first, even if today it only has one exit.
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
Master this one call and you've essentially learned all three. That's not a figure of speech — the next two recipes are this exact code with two lines swapped. So rather than skim it as "the obvious one," read it as the reference specimen: everything that follows is defined by how little it differs from this. If you can explain every line here, the Bedrock and Vertex recipes will feel like spotting the difference in two nearly-identical photos.
The one idea worth pausing on is where the secret lives. Notice you never type your API key in the code — you write Anthropic() with empty parentheses, and it quietly picks up ANTHROPIC_API_KEY from the environment. This is deliberate and it's a habit, not a nicety: a key hard-coded in a source file is a key that ends up in Git history, in a screen-share, in a pasted snippet on a forum. Reading it from the environment means the same code runs on your laptop and in production with no edits, and the secret stays out of the repo. The common rookie mistake — Anthropic(api_key="sk-ant-...") pasted straight in — works for exactly as long as it takes someone to git push it to a public repo.
Anthropic() pulls ANTHROPIC_API_KEY from the environment, then the same messages.create returns the reply. Empty parentheses is the whole security habit.
Follow the arrows left to right — the secret enters from the left and the answer comes out on the right.
- The green box is the environment holding
ANTHROPIC_API_KEY— the key lives here, outside your source file and out of Git history. - The middle boxes are the two code lines:
Anthropic()with empty parentheses quietly reads that key, thenmessages.createmakes the ordinary request. - The arrow out to
respis the reply you read viaresp.content[0].text.
In short: Read the key from the environment, and the same code runs on your laptop and in production with the secret never touching the repo.
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
This is the recipe most enterprises actually ship. If your company runs on AWS — and a huge share do — Bedrock is the door that lets you use Claude without a single byte of AI traffic leaving the account your security and finance teams already govern. The request goes to Claude, gets answered by Claude, and never appears on a separate Anthropic invoice or under a separate data-processing agreement. For a regulated shop, that difference isn't cosmetic; it's the difference between "approved" and "blocked."
The mental shift from Recipe 1 is about who vouches for you. On the direct API you carry a bearer key — like showing a membership card. On Bedrock there's no key at all; instead you arrive as an identity your AWS account already knows (an IAM role attached to your Lambda or EC2, or your local ~/.aws profile), the way an employee badge opens the office because the building's own system recognizes it. Auth becomes AWS's job, not Anthropic's. That's why the constructor takes an aws_region and no secret — the credential is ambient, supplied by the environment your code runs in.
Two gotchas trip nearly everyone the first time, and both are AWS-side, not code. First, your IAM principal needs permission to call bedrock:InvokeModel — without it you get an access-denied error that looks like a bug in your code but isn't. Second, and more surprising: a fresh AWS account has Claude switched off until you explicitly request model access in the Bedrock console for that region. Miss this and the call fails even with perfect credentials. And note the model-ID quirk that defines this door: Bedrock IDs carry an anthropic. prefix (anthropic.claude-opus-4-8). Send a bare alias here and it 404s; send this prefixed form to Vertex and that 404s — which is exactly the sharp edge the mapper in section 7 exists to hide.
anthropic.-prefixed id. Two AWS-side gates (invoke permission and enabled model access) must be set before the first call succeeds.
Read the top row left to right — that's the request path — then the two red boxes below, which are the setup that must already be in place.
- The amber box on the left is your AWS identity: an IAM role or local profile. There is no API key — the credential is ambient.
- The middle box is
AnthropicBedrock, which sends the request inside your own AWS account, and the right box is the model id with its Bedrock-onlyanthropic.prefix. - The two red boxes are the gotchas: your principal needs
bedrock:InvokeModel, and Claude must be enabled in the Bedrock console for that region. Miss either and the call fails even with perfect code.
In short: Same request as the direct API, but auth is AWS's job and the id gains a prefix — plus two console/IAM gates you set once.
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
Same story, third cloud. If your organization lives on Google Cloud instead of AWS, Vertex AI is your matching door — the exact analogue of Bedrock, just wearing Google's badge system instead of Amazon's. By now you should be able to predict what changes before you even read the code: the client constructor and the model-ID, and nothing else. If that prediction feels obvious, the chapter has done its job; the whole point of "run anywhere" is that the third example teaches you nothing new about the conversation, only about the doorway.
The doorway detail here is Application Default Credentials (ADC), and it's worth understanding rather than memorizing. Google's idea is that your code should never hold a Google secret directly; instead it asks the surrounding environment "who am I?" and the environment answers. On your laptop you establish that identity once by running gcloud auth application-default login — like signing in to your work laptop in the morning so every app inherits your identity. On GCP compute, the attached service account plays the same role automatically. So you hand the constructor a project_id and a region — where to run — but never a credential; the credential is discovered, just as Bedrock discovered your IAM role.
The mistake to avoid is assuming the model-ID rule matches Bedrock's. It doesn't, and that asymmetry is the whole reason model-IDs are the top portability footgun. On Vertex, current-generation models use the bare alias — claude-opus-4-8, no prefix — the same string the direct API uses and the opposite of Bedrock's prefixed form. (Older dated snapshots on Vertex use an @date suffix, but that's a versioning detail, not the everyday case.) Two clouds, two different naming conventions, one bare-vs-prefixed trap: this is precisely why you'll centralize the ID in a single mapper rather than typing it at each call site.
project_id and region but never a secret — and its model id is the plain alias, matching the direct API and diverging from Bedrock.
Follow the top row left to right; the two lines beneath it call out the model-id rule.
- The red box is Google Application Default Credentials — established once with
gcloud auth application-default loginor supplied by the compute's service account. The credential is discovered, never typed into code. - The middle box is
AnthropicVertex, taking aproject_idandregion— where to run, not who you are. - The green box is the model id: the bare alias, identical to the direct API and the opposite of Bedrock's prefixed form — the exact asymmetry the mapper exists to absorb.
In short: Vertex mirrors Bedrock (identity, not key) but flips the id rule back to bare — two clouds, two conventions, one trap.
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's ever going to confuse you about these three doors fits in the table below — that's genuinely good news. You've now written the same request three times; the reason it felt repetitive is that it is repetitive by design. This section collapses the three recipes into one grid so you can see, at a glance, the small set of things that actually differ. When a call fails after a provider swap, this table is your first stop: the bug is almost always in one of these four rows, never in the fifth.
Read the table as two zones with very different meanings. The top four rows (client, auth, model-ID, install extra) are the per-provider knobs — the plug shape and voltage from the adapter analogy. The bottom row is the promise: messages.create is identical everywhere. That single "identical" is what portability rests on. Everything you know about prompts, system messages, tools, streaming, and caching transfers untouched; you are only ever relearning the four setup rows, and even those you'll soon hand to a factory so you never think about them again.
The subtle trap in this table is treating the four variable rows as independent when they travel together. You don't mix a Bedrock client with a bare model-ID, or a Vertex client with the anthropic. prefix — each column is a consistent set. Pick a provider and you inherit its whole column at once: its client class, its auth mechanism, its ID format, its install extra. That coupling is exactly why the next section encodes the columns as a lookup rather than leaving four independent decisions scattered across your code, where they can drift out of sync.
Read it top to bottom as two zones separated by the amber note.
- The four amber rows are the per-provider knobs — client class, auth mechanism, model-id format, and pip install extra. Each one changes depending on which door you pick.
- The amber caption line is the key insight: those four rows are exactly what a factory sets from a single config value, so you never touch them by hand.
- The green bottom row is the promise —
messages.createis identical on every provider. Everything you know about prompts, tools, streaming, and caching transfers untouched.
In short: When a swap breaks, the bug is in one of the four amber rows — never in the green one.
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
Here's the small function that quietly prevents the most common production incident in a multi-provider app. Remember the bare-vs-prefixed model-ID trap? Left to chance, it surfaces at the worst possible moment: you flip your app to Bedrock for a compliance deadline, and every request 404s because a model-ID string buried in some helper was written for Vertex. The fix isn't to "be more careful" — it's to make it impossible to be careless, by giving the naming rules exactly one home.
Think of this mapper as the single source of truth for a fact that would otherwise be scattered as folklore across your codebase. It's the difference between a company where everyone remembers the office alarm code (until one person misremembers) and a company where the code is written in one locked, auditable place. The value isn't the three lines of logic — it's that the anthropic. prefix rule now exists in one location you can point to, test, and change. When Anthropic ships a new model or a naming convention shifts, you edit one dict instead of grepping the repo and praying you caught every occurrence.
Notice a deliberate design choice that pays off enormously: this helper is pure stdlib — no SDK import, no key, no network — so it runs with a plain python file.py and, crucially, in your CI test suite. The temptation is to bake the ID formatting into the client-building code, but that welds a pure decision (which string?) to an impure one (build a real client, needs creds). Keeping them apart means the sharp, error-prone part — the naming — is the part you can unit-test to death offline, while the part that needs credentials stays thin. That separation is what makes the whole abstraction trustworthy rather than just tidy.
anthropic. prefix only on the Bedrock branch, so no call site has to remember it.
Read left to right: a request goes in, the transform happens in the middle, two possible outputs come out on the right.
- The left box is the input — a provider plus a logical model name like
"opus", so your app never types a raw id. - The middle box is
pick_provider, the one place the naming rule lives. - The two right boxes are the outcomes: the green one shows
apiandvertexgetting the bare alias, while the amber one showsbedrockgetting theanthropic.prefix. The branch is the entire value of the function.
In short: The prefix rule exists in exactly one testable place — add a new model and it inherits the rule for free.
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
Sooner or later someone senior will ask you: "why are we on Bedrock and not just calling Anthropic directly?" — and "the model is the same either way" is the answer that gets you in trouble. It's true, and it's beside the point. The person asking already assumes the model is identical; what they want to know is which non-model factors drove the choice. This section is your cheat sheet for that conversation, because portability is only worth having if you can articulate why you'd actually exercise it.
Here's the reframe that makes the decision click: you're not choosing a model, you're choosing a billing relationship, an identity system, and a data-residency guarantee. Picture buying the identical bottle of water from a hotel minibar, a corner shop, or your own fridge — same water, wildly different context around it. The direct API is the corner shop: quick, no commitment, but a separate receipt. Bedrock and Vertex are your own fridge: the water is already inside the house you control, on the bill you already pay, behind the locks you already manage. For a regulated enterprise, "inside the house" is frequently non-negotiable, and that — not model quality — settles the choice.
The mistake to avoid is optimizing the wrong axis. Teams sometimes agonize over latency micro-differences or chase whichever door is fractionally cheaper this quarter, when the real driver is sitting in the compliance column. A blunt but reliable heuristic: if you already live in AWS or GCP, use the matching door — your AI spend, identity, and data then stay in the cloud your organization already trusts and audits, and you inherit its committed-use discounts for free. If you have no such constraint, the direct API wins on simplicity and gets new features first. The table below sorts the genuine drivers so you can point at the one that actually applies rather than defending a gut call.
Read left to right: the drivers on the left, the arrows carrying them to the door they select on the right.
- The three amber boxes — data residency, existing cloud commitment, and identity — all point to the matching cloud door: use Bedrock if you're on AWS, Vertex if you're on GCP.
- The green box — simplicity and getting new features first — points to the direct API, the least-setup path.
- Notice what's absent: model quality. Every arrow is about billing, governance, and location — never the model, because the model is the same on all three.
In short: If you already live in AWS or GCP, use the matching door; with no such constraint, the direct API wins on simplicity.
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
This is the section that keeps a cheerful "it's fully portable!" from becoming a broken cutover. Everything so far has sold you on sameness — same model, same call, same response. Now the honest caveat: the models are identical, but the surrounding features are not all present on every door at the same time. New capabilities land on the direct Anthropic API first and roll out to Bedrock and Vertex on their own schedules. Ignore that and you can ship a workload to a cloud where a feature it depends on simply doesn't exist — a failure that no amount of clean factory code prevents.
The reframe that saves you is this: portability is a property of your feature set, not of the SDK. The SDK will happily build you a Bedrock client; whether your app survives on Bedrock depends entirely on which features it leans on. Think of it like electrical appliances abroad — the plug adapter (the SDK) fits every socket, but a 110-volt appliance still won't run on 240-volt power. An app that uses only messages.create with tools and caching is the dual-voltage appliance: it runs anywhere. An app built on the Message Batches API or the Files API is single-voltage — it will not run on Bedrock or Vertex, because those endpoints aren't there at all, adapter or no adapter.
So the discipline is to know which bucket each code path is in before you assume a swap is free. The core — messages, streaming, tools, prompt caching, extended thinking — is everywhere, which is why portability is real for the large majority of applications. The gaps live in the newer, peripheral surfaces: Batches, Files, and some server-side tools like web fetch and code execution. The common and expensive mistake is discovering this at runtime, with a 404 in production during a compliance-driven migration. The fix is cheap and offline: enumerate the features your workload actually uses, check them against the table below, and gate anything provider-specific loudly at startup rather than letting it fail on a customer's request.
Two side-by-side buckets — sort each feature your app uses into one of them.
- The green bucket is the portable core: messages, streaming, tools, prompt caching, extended thinking. These work identically on all three doors — the "dual-voltage appliance" that runs anywhere.
- The red bucket is the peripheral gaps: Message Batches, Files, and some server tools like web fetch and code execution. These are direct-API-only or partial — "single-voltage," so a swap to Bedrock/Vertex breaks them.
- The payoff is the discipline: enumerate your features, drop each into a bucket, and gate anything in the red bucket loudly at startup instead of 404-ing in production.
In short: Only-core app → portable today; Batches/Files app → not a drop-in swap. Know which bucket you're in before you assume a move is free.
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
Everything in this chapter builds to one sentence a tech-lead should be able to say with a straight face: "moving providers is a config change, not a code change." That sentence is a promise to your security team, your finance team, and your future on-call self — and this section is the code that lets you keep it. When the compliance review lands and someone asks "how fast can we get this onto Bedrock?", the answer you want to give is "flip one environment variable and redeploy," not "give me a sprint."
The pattern is a single funnel. Recall the power-adapter and the "one locked room for the alarm code" analogies — here they finally converge into one factory function that is the only place in the entire application allowed to name a provider. Business logic asks the factory for a client and then calls messages.create; it never imports AnthropicBedrock, never sees an aws_region, never learns which cloud answered. The two provider-aware concerns — which client (the factory) and which model-ID (the mapper from section 7) — are quarantined into two small, testable functions, and the rest of your codebase stays provider-blind.
The mistake that silently erodes this — and it happens on real teams — is the "just this once" leak: a developer under deadline imports AnthropicVertex directly in some module because it's faster than plumbing through the factory. Each such shortcut re-welds a piece of the app to one provider, and the config-change promise quietly dies the death of a thousand cuts. So the discipline is enforceable, not aspirational: no module except the factory imports a provider client, and you can grep for violations in CI. Read the code below as the template you'd wire into a real service, then study the tech-lead checklist that follows — it turns this single pattern into the handful of rules that keep an integration portable under real-world pressure.
make_client() — the sole place allowed to name a provider — then calls a provider-blind messages.create. A direct provider import (red) bypasses the funnel and quietly re-welds the app to one cloud.
Read it left to right as a funnel, then note the red box below as the anti-pattern.
- The three blue boxes on the left are your business modules — they all converge on one point instead of each knowing about providers.
- The middle box is
make_client(), the only provider-aware code; the arrows fan into it because it's the single funnel. From there the green box is the provider-blindmessages.createthat never learns which cloud answered. - The red box is the "just this once" leak — a module importing
AnthropicVertexdirectly, bypassing the funnel. The dashed line shows it sneaking past the factory; this is the shortcut you grep for and forbid in CI.
In short: Keep exactly one door out of the funnel and "moving providers is a config change, not a code change" stays true.
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?