●MODEL — Claude Fable 5 reached general availability on June 9 with a 1M-token context, always-on adaptive thinking, and 128K output●PLATFORM — The Developer Platform adds code execution, an MCP connector, a Files API, and prompt caching up to one hour●MCP — Admins can provision MCP connectors org-wide via Okta, giving users zero-touch access on first login●SANDBOX — Claude Managed Agents now run in your own sandbox and connect to private MCP servers●CODING — Opus 4.8 scores 72.5% on SWE-bench and 43.2% on Terminal-bench, excelling at long-running work●LINEUP — Opus 4.8, Sonnet 4.6, and Haiku 4.5 lead the lineup; pick the right one per task●MODEL — Claude Fable 5 reached general availability on June 9 with a 1M-token context, always-on adaptive thinking, and 128K output●PLATFORM — The Developer Platform adds code execution, an MCP connector, a Files API, and prompt caching up to one hour●MCP — Admins can provision MCP connectors org-wide via Okta, giving users zero-touch access on first login●SANDBOX — Claude Managed Agents now run in your own sandbox and connect to private MCP servers●CODING — Opus 4.8 scores 72.5% on SWE-bench and 43.2% on Terminal-bench, excelling at long-running work●LINEUP — Opus 4.8, Sonnet 4.6, and Haiku 4.5 lead the lineup; pick the right one per task
When the Same Model Has a Different Name Everywhere — Designing a Cross-Provider Model Identity Resolver for Claude
Now that Fable 5 is available on the API, Bedrock, and Vertex at once, the same model carries a different identifier on each. Here is how to untangle hardcoded model strings with a small resolver that maps logical names to physical IDs, carries capability flags, and verifies identifiers at startup.
The day Fable 5 became generally available, I tried to wire it into my unattended automation and ran straight into a quiet wall. I normally call the Claude API directly, but for one job I wanted to lean on Amazon Bedrock for latency and region reasons. The model string sitting in my code simply did not work there.
It was supposed to point at the same "Fable 5," yet the name was completely different depending on where I called it: claude-fable-5 on the API, an inference-profile ARN on Bedrock, a publisher model path on Vertex AI. As an indie developer running everything solo, that gap is more than a minor annoyance. When the model string is scribbled across the codebase, adding a single provider means hand-editing every call site.
This article walks through collapsing those scattered identifiers behind one layer of abstraction — "logical model name → physical identifier" — and operating it safely, capability differences and existence checks included, with implementation code.
Hardcoded model strings quietly stall your migration
Let me first make the cost of hardcoding concrete. In my pipeline, separate modules called Claude for different jobs: article generation, summarization, classification, integrity checks. When I went to try Fable 5 and counted with grep -rn 'claude-' src/ | grep -c model, I found the model: string written inline in 12 places.
Several of those still carried old Sonnet-era identifiers, and there was no way to tell from the code which were live and which were lingering out of inertia. Before even talking about switching providers, I had lost the ability to answer a basic question: which model am I calling, where, and how many times?
The root reason hardcoding stops working is that a model string compresses three separate concerns into one token: which generation/capability tier the model is (logical intent), which provider and identifier it lives at (physical location), and when it was pinned (version stability). When those three aren't separated, changing one of them forces you to rewrite the whole string.
Introduce one layer of logical model names
The starting point is simple: let the code reference only a "logical model name," and confine the translation to a physical identifier to a single place. Name the logical model by capability or role. Names like reasoning-default, fast-cheap, or long-output let the calling code express what it wants to do, so the call site reads in terms of intent rather than vendor strings.
The physical identifier is determined by the pair of logical name and provider. Keep that mapping as a single table.
Logical name
Provider
Physical identifier (example)
reasoning-default
anthropic
claude-fable-5
reasoning-default
bedrock
(inference-profile ARN)
reasoning-default
vertex
(publisher model path)
fast-cheap
anthropic
claude-haiku-4-5-20251001
Not writing the literal identifiers into the table is deliberate. ARNs and model paths get injected as environment variables or secrets; the table holds only a "key." That keeps the identifiers off the codebase, so swapping a region or project is a configuration change rather than a code change.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A small resolver that maps a logical model name to the right physical identifier per provider, plus the migration steps to collapse a dozen scattered model strings into one place
✦A capability-flag design that bundles per-provider differences (1-hour prompt cache, 128K output, extended thinking) with the identifier so features degrade gracefully instead of breaking silently
✦A low-cost startup preflight that confirms the resolved identifier actually exists on that provider, and three operational pitfalls I hit running it
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
A resolver that resolves identifiers by environment
With the abstraction in place, write the function that actually resolves. Look at the before and after first.
Before — provider-specific strings leak into the call site.
// Before: the physical identifier is hardcoded at the call siteconst res = await client.messages.create({ model: "claude-fable-5", // does not work on Bedrock max_tokens: 4096, messages,});
After — the call site knows only the logical name.
// After: pass only a logical name; confine resolution to the resolverconst m = resolveModel("reasoning-default");const res = await client.messages.create({ model: m.id, max_tokens: Math.min(4096, m.maxOutputTokens), messages,});
The resolver itself reads the execution environment (which provider it points at) once, matches it against the logical name, and returns the physical identifier plus capability information.
type Provider = "anthropic" | "bedrock" | "vertex";interface ResolvedModel { id: string; // the identifier actually used on that provider provider: Provider; maxOutputTokens: number; supportsPromptCache: boolean; supportsLongCacheTtl: boolean; // extended cache with 1-hour TTL}// physical identifiers are injected from env, never baked into codeconst REGISTRY: Record<string, Partial<Record<Provider, ResolvedModel>>> = { "reasoning-default": { anthropic: { id: "claude-fable-5", provider: "anthropic", maxOutputTokens: 128_000, supportsPromptCache: true, supportsLongCacheTtl: true, }, bedrock: { id: process.env.BEDROCK_FABLE5_PROFILE ?? "", provider: "bedrock", maxOutputTokens: 64_000, // match the real per-env ceiling supportsPromptCache: true, supportsLongCacheTtl: false, }, },};function currentProvider(): Provider { return (process.env.CLAUDE_PROVIDER as Provider) ?? "anthropic";}export function resolveModel(logical: string): ResolvedModel { const p = currentProvider(); const entry = REGISTRY[logical]?.[p]; if (!entry || !entry.id) { throw new Error( `Model resolution failed: logical=${logical} provider=${p} (identifier not configured)` ); } return entry;}
The key is to stop with an exception when resolution fails. If an empty identifier reaches the API, the provider returns a generic error like model: invalid, and you can no longer tell whether the cause is a missing config or a retired model. Failing at the resolver and naming exactly which logical name is unconfigured on which provider shortens the investigation dramatically.
Bundle capability differences with the identifier
The scariest part of going multi-provider is assuming capabilities are identical once the identifier resolves. Even the same Fable 5 may or may not support a 1-hour-TTL extended prompt cache depending on provider or contract, and the real output-token ceiling can differ. If the call site assumes these blindly, behavior silently changes in just one environment.
So carry capabilities alongside the model identifier, and always branch on a flag before using a feature. For prompt caching, for example:
const m = resolveModel("reasoning-default");const cacheControl = m.supportsLongCacheTtl ? { type: "ephemeral", ttl: "1h" as const } : m.supportsPromptCache ? { type: "ephemeral" as const } // degrade to the default TTL : undefined; // environment has no caching at allconst system = cacheControl ? [{ type: "text", text: SYSTEM_PROMPT, cache_control: cacheControl }] : SYSTEM_PROMPT;
Going through a capability flag means an unsupported environment runs with the feature stepped down rather than failing with an error. In my pipeline I split long single-pass generation into a long-output logical name and set its maxOutputTokens to the measured ceiling, which eliminated the case where a max_tokens value exceeded the provider ceiling and got rejected. The rule is to confirm a capability with a flag before using it, not to assume it exists.
Preflight the identifier's existence at startup
The identifier the resolver returns is, after all, just a configured value. A typo in an ARN, or pointing at a model not yet rolled out to that region, is invisible to the resolver alone. Rather than discovering it at the moment of production traffic, confirm once at startup, at minimal cost, that "this identifier really exists on this provider."
A minimal-token dummy call is enough. Send a tiny request with max_tokens: 1 and check only that authentication, identifier, and region line up.
export async function preflight(logical: string): Promise<void> { const m = resolveModel(logical); try { await client.messages.create({ model: m.id, max_tokens: 1, messages: [{ role: "user", content: "ping" }], }); } catch (e: any) { // 404/NotFound-class errors signal "identifier not present on this provider" throw new Error( `Preflight failed: ${logical} -> ${m.id} @ ${m.provider} / ${e?.status ?? "?"} ${e?.message ?? e}` ); }}
For pipelines that run unattended, this small step pays off. In my scheduled posting tasks I preflight only the logical names I'm about to use, before the main work begins. Catching a mistaken identifier on the very first run after a config change avoids the worst path — everything fails overnight and I find out the next morning. Since it's max_tokens: 1, the verification cost is essentially negligible.
Make upgrades safe with pinning and floating aliases
Logical model names bring one more welcome side effect: it becomes easy to build in an upgrade safeguard. Keep two kinds of logical names. One is "pinned" to a specific snapshot (for example, a date-suffixed identifier); the other is a "floating alias" that always points at the latest.
Critical production paths use the pinned name so the model's behavior cannot change underneath you. Less impactful auxiliary or evaluation paths use the floating alias, observing a new model's behavior at low risk. On the resolver table this is simply having two logical names.
// the same physical model held under both a pinned and a floating name// "reasoning-pinned" -> date-stamped snapshot (stable production path)// "reasoning-latest" -> floating alias (evaluation / non-critical path)
When a new generation ships, you compare outputs on the reasoning-latest path first, and if it looks fine, update the identifier that reasoning-pinned points at — a promotion procedure you run with a config change, no code edits. Back when I hardcoded the model string, "try the new model on just part of the traffic" was quietly tedious.
Three pitfalls I hit in operation
Finally, a few things I noticed actually running this.
First, provider mix-ups happen silently. Forget to set the CLAUDE_PROVIDER environment variable and the resolver falls back to the default anthropic. That default is safe in itself, but it leads to "I thought I'd moved to Bedrock but was hitting the API." I now always print the resolved provider and id to the startup log so I can eyeball it.
Second, stale capability flags. If the provider later adds 1-hour caching but the table still has supportsLongCacheTtl: false, you keep running with the degraded setting. Because no error fires, it's easy to miss, so it's practical to make capability flags a checklist item you update whenever you read the release notes.
Third, floating-alias drift. If you carelessly use reasoning-latest on a primary production path, the model can update one day, output tendencies shift, and your quality gates react unexpectedly. Spell out that the floating alias is "for observation," and strictly keep critical production paths on a pinned name.
Scattered identifiers only reveal themselves as pain the moment you try to add a provider. Even if hardcoding hasn't bitten you yet, count your model: occurrences with grep once more. If it's into double digits, replacing just one with a logical name is a good first step. I started by moving only reasoning-default through the resolver, and migrated the rest little by little.
Thanks for reading. I hope it gives anyone else juggling several call paths alone a nudge to untangle their configuration.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.