Field Notes Blog cover
LLM PLATFORM

Upgrading an Agent Platform to Claude Opus 5 on Amazon Bedrock: Eight Gotchas We Hit

Billed for a full response, parsed an empty string. A 400 misread as "model unavailable." Thinking that burns max_tokens before the answer starts. The migration was worth it — but not without friction.

A
Ashutosh Upadhyay
Platform Engineering · September 2026

Updated 4 October 2026 with technical corrections.

The first real Opus 5 call in our staging environment returned a full bill and an empty string — that thinking-block trap is the starting point for the section below.

This post documents eight production gotchas from migrating a multi-stage research agent platform from Claude Sonnet to global.anthropic.claude-opus-5 on Amazon Bedrock. The migration was worth it — the quality improvement on synthesis tasks was real and measurable. But the path from "swap the model ID" to "this is actually working correctly" took more than a weekend. If you're planning a similar migration, here is what we would have liked to know in advance.

Gotchas 1 & 2: Two We've Covered Before

Two of the Opus 5 breaking changes are documented in full in FinOps for Agentic AI and only need a brief recap here. First, content[0].get("text", "") returns an empty string when the first block is a thinking block — not an error, just silently empty (content[0]["text"] fails with a KeyError instead, intermittently); the fix is to filter content blocks by type == "text" rather than index by position. Second, temperature / top_p are rejected with a ValidationException on Opus 5; if your error handler classifies any ValidationException as "model not available," it silently downgrades to a fallback model — a request defect must surface as an error, not a substitution.

Two details are specific to Opus 5 that weren't in the earlier post: if you're using Strands' BedrockModel on the Converse path, you're unaffected by the content-block issue — Strands parses reasoningContent correctly; only code that parses responses itself — raw invoke_model, or raw converse reading content[0] — needs the type-filter fix. And Opus 4.6 and earlier still accept temperature, so a blanket strip across all Anthropic models silently changes sampling on older versions — apply the parameter strip by version family, not globally.

Gotcha 3: Thinking Is Adaptive, Not Always-On

We initially assumed that Opus 5's extended thinking was always active — that every call would produce a thinking block and pay thinking-token costs. That assumption was wrong.

With no explicit thinking field in the request, Opus 5 elects whether to think based on the difficulty of the prompt. Simple or short prompts return blocks=['text'] with thinking_tokens=0. Complex reasoning tasks return blocks=['thinking','text']. This is true on both the raw invoke_model path and the Converse path.

To disable thinking explicitly — for example, in a high-volume, low-complexity workflow where you want predictable latency:

# Raw invoke_model
body["thinking"] = {"type": "disabled"}

# Strands BedrockModel (Converse path)
model = BedrockModel(
    model_id="global.anthropic.claude-opus-5",
    additional_request_fields={"thinking": {"type": "disabled"}}
)

Hard constraint: disabled thinking works only at effort high or below. Combining effort: "xhigh" or "max" with "thinking": {"type": "disabled"} throws a ValidationException. This is validated per request — a later call that raises effort will fail even if earlier calls in the same session passed. Don't disable thinking casually; Anthropic's Prompting Claude Opus 5 guide documents two failure modes it can trigger: tool calls emitted as plain text (silently never executed) and <thinking> tags leaking into output. Lower effort instead if cost is the concern.

Gotcha 4: Thinking Bills Against max_tokens — and Will Fill All of It

This is the most consequential gotcha. On Opus 5, reasoning tokens bill against the same max_tokens ceiling as the text response. Without an explicit effort (the default is high), thinking can expand to consume as much of that budget as it can — and the text response is truncated, or never emitted at all.

We measured this on Bedrock Converse with global.anthropic.claude-opus-5, varying effort and the max_tokens floor on a fixed reasoning-heavy prompt:

Configurationstop_reasonOutput tokensChars in response
No effort, cap 1024 max_tokens 1024 0 — empty
effort=low, cap 1024 max_tokens 1024 2800 — truncated mid-sentence
No effort, floor 8000 ReadTimeoutError — Client read timeout — still thinking at default (high) effort
effort=low + floor 8000 end_turn 2693 7780 — clean

The second row is the dangerous one: effort=low alone returns plausible text that is cut off mid-sentence. An empty response is obviously broken and you notice immediately. A truncated response silently grades as a wrong answer, a bad summary, or — if you're expecting JSON — a parse failure you'll attribute to prompt formatting.

Both knobs are required, and testing them together hides the problem. If you set both effort and a generous max_tokens in your initial tests, everything looks fine. Only when one of those is absent does the truncation appear. Test the knobs independently — set effort without raising the cap, and see whether the response is complete.

The stop_reason field tells you whether output is complete. Always check it before doing anything with the response:

response = converse_client.converse(...)
stop_reason = response["stopReason"]

if stop_reason == "max_tokens":
    # Response is incomplete — raise an error specific to this condition.
    # Do NOT score this. Do NOT return it to users. Do NOT log it as a model failure.
    # It is a harness/config limit, not a model weakness.
    raise TokenBudgetExceededError(
        f"Response truncated at {response['usage']['outputTokens']} tokens. "
        "Raise max_tokens or lower effort."
    )

# Only process if stop_reason == "end_turn"
if stop_reason != "end_turn":
    raise UnexpectedStopReasonError(stop_reason)   # e.g. content_filtered, guardrail_intervened
text = extract_text_from_converse_response(response)

One more fingerprint worth knowing: stopReason is max_tokens and the response has no text block (or only an empty one) — the thinking consumed the entire budget. Converse doesn't report thinking tokens separately, so outputTokens alone can't tell you. You paid full price for nothing, and the call still returns HTTP 200.

Grade the grader too. If you have an LLM-as-judge evaluation harness, the same truncation issue applies to the judge. We fixed our internal model-evaluation harness's candidate path but missed the judge — which was running at a 4096 cap with effort=medium and no stop_reason check. A truncated judge verdict that happened to parse as valid JSON produced a real score from an incomplete deliberation. We were diagnosing model quality differences that were actually evaluation harness artifacts. Check stop_reason in every LLM call, including the judge.

Gotcha 5: Effort Nests Under output_config — and It's Live, Not Inert

When we probed in July, Bedrock's Converse API had no top-level effort field, so effort went inside additionalModelRequestFields, nested under output_config (newer SDK versions also include a native outputConfig.effort field):

# Correct nesting
response = bedrock_client.converse(
    modelId="global.anthropic.claude-opus-5",
    messages=[...],
    additionalModelRequestFields={
        "output_config": {"effort": "medium"},
        "thinking": {"type": "adaptive"}
    },
    inferenceConfig={"maxTokens": 8000}
)

Both {"effort": "medium"} and {"effort": {"type": "medium"}} at the top level of additionalModelRequestFields throw ValidationException. The error message mentions the field name but not the nesting requirement, so this one costs time to debug by elimination.

It's worth verifying that effort is actually doing something, not just being silently ignored. We measured output tokens on a fixed reasoning-heavy prompt across effort levels on global.anthropic.claude-opus-5:

Effort levelOutput tokens (same prompt)
low1218
medium1811
high2972

The parameter is live. A misconfigured effort value that throws a ValidationException and falls back to default isn't neutral — it changes the thinking depth and therefore the output quality and cost of every call.

Gotcha 6: There Is No Dated Opus 5 ID — and You Don't Need One

Opus 5 on Bedrock is reached through a cross-region inference profile — geo (us., eu., au., in.) or global; we use global.anthropic.claude-opus-5. From the 4.6 generation on, Claude model IDs are dateless, so there is no dated suffix to add on any profile:

global.anthropic.claude-opus-5           # resolves
global.anthropic.claude-opus-5:0         # ValidationException
global.anthropic.claude-sonnet-4-6:0     # ValidationException

That is not a pinning problem: the dateless ID is the pinned snapshot. Anthropic never changes the weights behind an existing model ID — an update ships under a new ID. If you need a reproducible model version for an LLM judge or a grading baseline — where you want to be able to say "this score was produced by model version X" — record the exact ID you sent with every score; that is your model version.

Do not write a plausible dated ID as a placeholder. A hand-constructed ID like global.anthropic.claude-opus-5-20260901-v1:0 looks valid, passes a regex-based format check, and then fails at Bedrock with a ValidationException that reads like "model unavailable." You'll spend time debugging access rather than the ID itself. For Opus 5, "no dated ID" is the permanent answer — record the dateless ID rather than leaving a TODO.

This has a practical implication for evaluation infrastructure. A score recorded against global.anthropic.claude-opus-5 is attributable to a fixed model version. What can still change under a fixed ID is the serving layer — routing, safety classifiers — so for an evaluation baseline you intend to compare against in six months, a nightly drift check is still worth having. Older models (Haiku 4.5, Sonnet 4.5, Opus 4.5) still carry dated IDs, for example:

global.anthropic.claude-haiku-4-5-20251001-v1:0   # resolves

See Benchmarking LLMs in the Enterprise for a broader treatment of model version stability in evaluation pipelines.

Gotcha 7: boto3 Floor Is Higher Than You Think

This one is easy to miss because the failure mode is silent. botocore 1.35.x parses Opus 5's Converse reasoningContent field as SDK_UNKNOWN_MEMBER and drops it. If your code relies on the Strands BedrockModel reasoning output, the reasoning simply isn't there — no error, no warning, just missing data.

The floor is boto3>=1.42.31 once you set a cache TTL: the CacheToolsConfig(ttl="1h") setup in Gotcha 8 sends cachePoint.ttl, which older botocore rejects client-side with a ParamValidationError. reasoningContent parsing on its own needs 1.37.0. If your environment was pinned at >=1.35.0 (which was sufficient for the previous model generation), the thinking output disappears silently when you upgrade to Opus 5.

# requirements.txt — wrong floor for Opus 5
boto3>=1.35.0     # ← SDK_UNKNOWN_MEMBER on reasoningContent

# Correct (cachePoint.ttl needs 1.42.31; reasoningContent alone needs 1.37.0)
boto3>=1.42.31

Check this before anything else if reasoning output looks absent or incomplete after a model upgrade.

Gotcha 8: Prompt Caching Migration — TTL Ordering, Checkpoint Budget, and Cross-User Hits

Our Sonnet tier's prompt-cache hit rate was 70.5%. That figure is worth pausing on: 70.5% sounds decent until you look at what it means. Roughly 30% of input tokens were paying full price, and the miss rate had a code cause — no per-turn message cache point was being injected. That gap is what moved us onto the typed caching API.

Strands' prompt-caching API has moved to typed config (we were on 1.51.0). cache_prompt is deprecated:

# Old API — cache_prompt is deprecated (emits a UserWarning)
model = BedrockModel(
    model_id="global.anthropic.claude-opus-5",
    cache_prompt="default",
    cache_tools="default"
)
# Typed config (Strands 1.51.0)
from strands import Agent
from strands.models.bedrock import BedrockModel, CacheConfig, CacheToolsConfig

model = BedrockModel(
    model_id="global.anthropic.claude-opus-5",
    cache_config=CacheConfig(strategy="auto"),          # message cachePoint per turn; 5m TTL
    cache_tools=CacheToolsConfig(type="default", ttl="1h"),  # tools are static — 1h TTL
    max_tokens=16000
)

# The system-prompt cache point (what cache_prompt used to add) now lives in the system prompt
agent = Agent(
    model=model,
    system_prompt=[{"text": SYSTEM_PROMPT}, {"cachePoint": {"type": "default"}}],
)

There are three details in this migration that are easy to get wrong:

TTL Ordering

Cache entries with longer TTL must appear before entries with shorter TTL in the request. In the configuration above, the tools cache (1h TTL) appears before the message cache (5m TTL). That ordering is correct and required. Reverse it and the request will fail validation.

Checkpoint Budget

Opus 5 and Sonnet 4.6 allow 4 cache checkpoints per request. A typical configuration uses: system prompt (1) + tools (1) + messages (1) = 3 checkpoints. You have one checkpoint of headroom — stay within four. The silent failure is a different one: a checkpoint whose prefix is below the model's minimum (512 tokens on Opus 5) still succeeds but caches nothing.

Cross-User Cache Hits Require a Static System Prompt

Bedrock's prompt cache is keyed on the exact token content of the cached prefix. If your system prompt includes anything user-specific — an email address, a user ID, a role interpolated from an identity context — every user gets a different cache key and there are zero cross-user hits. The system prompt cache write cost is paid on every session start rather than being amortized across users.

The fix: move user identity out of the system prompt entirely. Put it in the first human turn message instead. In agent code, read user identity server-side from a request-scoped context variable when tools need it — never interpolate it into a prompt that participates in caching. With a static system prompt, every user who sends a similar request hits the same warm cache entry.

Cache reads cost roughly one-tenth of a standard input token; cache writes cost roughly 1.25× (2× for the 1-hour TTL used for tools above). A single cache hit on a large system prompt more than pays for the write. At production scale, the economics of cross-user cache hits depend entirely on whether the system prompt is static. Bedrock reports cacheReadInputTokens and cacheWriteInputTokens separately in the usage block — capture both, because cached tokens shift out of the standard input_tokens counter and cost reporting will undercount if you only look at the latter.

For a broader view of how model cost attribution works in a multi-agent system, see FinOps for Agentic AI.

Opus 5 on Bedrock — Migration Checklist

  1. Filter content blocks by type, not position. Replace any content[0]["text"] or content[0].get("text", "") pattern with a type-filtered join. Thinking blocks are first when present; position-based indexing fails intermittently based on prompt complexity.
  2. Update sampling parameter handling. Strip temperature, top_p, and top_k for Opus 4.7 and later and Sonnet 5 and later (including the 5.5 tier) — not for the 4.6 generation and earlier, which still honour them. Fix error classification so a ValidationException on bad parameters does not trigger a silent model downgrade.
  3. Never combine effort: "xhigh" or "max" with "thinking": {"type": "disabled"}. This throws a ValidationException per request.
  4. Set both effort and a raised max_tokens together. Effort alone produced truncated text; a generous cap alone outlasted the client's read timeout on a non-streaming call — raise read_timeout or stream. Test the knobs in isolation.
  5. Always check stop_reason. max_tokens means the output is incomplete. Raise a distinct error; do not score or surface a truncated response. Apply this check to every LLM call — including judge calls in evaluation harnesses.
  6. Nest effort under output_config inside additionalModelRequestFields. Effort at the top level of additionalModelRequestFields throws a ValidationException.
  7. There is no dated Opus 5 or Sonnet 4.6 ID — the dateless ID is the pinned version. Record it as sent; never write a placeholder dated ID.
  8. Floor boto3 at >=1.42.31. Botocore before 1.37.0 silently drops reasoningContent as SDK_UNKNOWN_MEMBER; before 1.42.31 it rejects the cache-point ttl used for 1-hour caching.
  9. Migrate to CacheConfig / CacheToolsConfig. cache_prompt is deprecated — put the system-prompt cache point in a SystemContentBlock. Keep tools TTL (1h) before message TTL (5m) in the request. Make the system prompt a plain constant with no user interpolation to enable cross-user cache hits.

Follow-up (October 2026): A Refusal Is an HTTP 200: Designing for Model Refusals on Amazon Bedrock — what changes on the 5.5 tier, where a refusal arrives as a successful response.

Amazon Bedrock Claude Opus 5 Agentic AI LLM Platform Strands Prompt Caching Extended Thinking
Share

Disclaimer: The views and opinions expressed in this post are my own and do not represent those of my employer or any organisation I am associated with.

Comments & Discussion