Bedrock — Streaming, Tool Use, Structured Output, Multimodal & Prompt Caching¶
The features you add to a plain converse call once a real application needs them. The output below comes from the local fake Bedrock server in page 04. The fake's chunk sizes and token numbers are invented: the output shows shapes and control flow, not real model behaviour.
ConverseStream¶
converse_stream returns {"stream": <event iterator>}. Events arrive as one-key dicts:
| Event | Payload | Use |
|---|---|---|
messageStart |
role |
Start of the assistant message |
contentBlockStart |
contentBlockIndex, start (toolUse with toolUseId and name, or toolResult, image) |
A tool call begins |
contentBlockDelta |
contentBlockIndex, delta: text, toolUse.input (a JSON string fragment), reasoningContent (text, signature, redactedContent), citation, image |
Append to the block with that index |
contentBlockStop |
contentBlockIndex |
The block is complete; parse tool JSON now |
messageStop |
stopReason, additionalModelResponseFields |
Why it ended |
metadata |
usage, metrics.latencyMs, trace (guardrail), performanceConfig, serviceTier |
Token counts arrive last, after the text |
| exceptions | internalServerException, modelStreamErrorException, validationException, throttlingException, serviceUnavailableException |
Failure after the 200 response started |
Example output from the fake (text reply, then a tool reply; the 5-character tool fragments are the fake's choice):
event types: ['messageStart', 'contentBlockDelta', 'contentBlockDelta', ..., 'contentBlockStop', 'messageStop', 'metadata']
first delta event: {'contentBlockDelta': {'delta': {'text': 'Your'}, 'contentBlockIndex': 0}}
tool input fragments: ['{"ord', 'er_id', '": "o', 'rd-42', '"}']
tool start event: {'contentBlockStart': {'start': {'toolUse': {'toolUseId': 'tooluse_abc123', 'name': 'lookup_order'}}, 'contentBlockIndex': 0}}
A reusable assembler:
import json
from dataclasses import dataclass, field
@dataclass
class StreamResult:
text: str = ""
tool_uses: list[dict] = field(default_factory=list)
stop_reason: str | None = None
usage: dict = field(default_factory=dict)
trace: dict | None = None
def consume_stream(stream, on_text=None) -> StreamResult:
res, pending = StreamResult(), {} # pending: contentBlockIndex -> tool call being built
for event in stream:
if "contentBlockStart" in event:
start = event["contentBlockStart"]["start"]
if "toolUse" in start:
pending[event["contentBlockStart"]["contentBlockIndex"]] = {**start["toolUse"], "raw": ""}
elif "contentBlockDelta" in event:
d = event["contentBlockDelta"]
if "text" in d["delta"]:
res.text += d["delta"]["text"]
if on_text:
on_text(d["delta"]["text"]) # push to the UI / websocket here
elif "toolUse" in d["delta"]:
pending[d["contentBlockIndex"]]["raw"] += d["delta"]["toolUse"]["input"]
elif "contentBlockStop" in event:
tu = pending.pop(event["contentBlockStop"]["contentBlockIndex"], None)
if tu:
res.tool_uses.append({"toolUseId": tu["toolUseId"], "name": tu["name"], "input": json.loads(tu["raw"] or "{}")})
elif "messageStop" in event:
res.stop_reason = event["messageStop"]["stopReason"]
elif "metadata" in event:
res.usage, res.trace = event["metadata"]["usage"], event["metadata"].get("trace")
return res
Streaming failure modes (a mid-stream error frame):
- An error after the first bytes is raised while iterating as
botocore.exceptions.EventStreamErrorwithresponse["Error"]["Code"] == "modelStreamErrorException". It is not the modeledclient.exceptions.ModelStreamErrorException, and botocore does not retry it because the HTTP call already succeeded. Partial text may already be delivered (for example['Your', ' ord']): decide whether to discard it, show it, or restart the call. - Errors before the stream starts (throttling, validation, access) are normal
ClientErrors raised byconverse_stream(...)itself and follow the retry rules below. - Use a larger
read_timeout(default 60 s) for long generations; idle gaps between events count. If traffic crosses a NAT gateway, interface endpoint or NLB, idle connections are dropped after 350 s: settcp_keepalive=Trueand lower the OSnet.ipv4.tcp_keepalive_timebelow 350 s (AWS troubleshooting guide). - With a guardrail, streaming has
sync(buffered, accurate) andasync(fast, may leak content before the block) modes: page 03. - Some models accept tools only on non-streaming
Converse: Llama 3.1/3.3/4, Mistral Large 1 and 2, Pixtral Large and Writer Palmyra X4/X5 rejectConverseStreamwith a tools config.
The Tool-Use Loop¶
The model never runs your function. It returns stopReason == "tool_use" with one or more toolUse blocks; you run them and send one user message with all toolResult blocks.
sequenceDiagram
participant A as Your code
participant B as Bedrock (Converse)
A->>B: messages + toolConfig
B-->>A: assistant message with toolUse (stopReason tool_use)
A->>A: run tool(s), catch exceptions
A->>B: + assistant message unchanged + user message with toolResult blocks
B-->>A: final assistant text (stopReason end_turn)
TOOLS = {"tools": [{"toolSpec": {
"name": "lookup_order",
"description": "Look up an order by id",
"inputSchema": {"json": {"type": "object",
"properties": {"order_id": {"type": "string"}},
"required": ["order_id"]}},
# "strict": True, # constrain tool names and inputs to the schema (supported models only)
}}]}
def run_tool_loop(client, model_id, messages, tool_config, handlers, *, system=None, max_tokens=1024, max_turns=6):
messages = list(messages)
for _ in range(max_turns): # always bound the loop
kwargs = dict(modelId=model_id, messages=messages, toolConfig=tool_config, inferenceConfig={"maxTokens": max_tokens})
if system:
kwargs["system"] = system
resp = client.converse(**kwargs)
message = resp["output"]["message"]
messages.append(message) # keep it unchanged: reasoning blocks must come back as-is
if resp["stopReason"] != "tool_use":
if resp["stopReason"] != "end_turn":
raise RuntimeError(f"stopped: {resp['stopReason']}")
return "".join(b["text"] for b in message["content"] if "text" in b), messages
results = []
for block in message["content"]:
if "toolUse" not in block:
continue
tu = block["toolUse"]
try:
out = handlers[tu["name"]](**tu["input"])
results.append({"toolResult": {"toolUseId": tu["toolUseId"], "content": [{"json": out}]}})
except Exception as exc: # tell the model; do not crash the request
results.append({"toolResult": {"toolUseId": tu["toolUseId"], "status": "error",
"content": [{"text": f"{type(exc).__name__}: {exc}"}]}})
messages.append({"role": "user", "content": results}) # ALL results of the turn in ONE user message
raise RuntimeError(f"no final answer after {max_turns} turns")
Trace of the conversation (the model replies are scripted):
user [{"text": "Status of ord-42?"}]
assistant [{"toolUse": {"toolUseId": "tooluse_abc123", "name": "lookup_order", "input": {"order_id": "ord-42"}}}]
user [{"toolResult": {"toolUseId": "tooluse_abc123", "content": [{"json": {"order_id": "ord-42", "status": "shipped"}}]}}]
assistant [{"text": "Order ord-42 has shipped."}]
Tool rules:
| Rule | Why |
|---|---|
inputSchema.json is a JSON Schema object (not a string) |
Different from structured output, where the schema is a string |
Echo toolUseId exactly; ids Bedrock generates start with tooluse_ (some models pass the provider's id through) |
The model matches results to calls by id |
Report failures with "status": "error" and a message |
The model can apologise or retry instead of hanging |
Parallel calls give several toolUse blocks in one message |
Return one toolResult per block, same user message |
toolChoice: auto (default, universal), any, tool |
any and tool are per-model; an unsupported value raises ValidationException. The Claude Sonnet 5.5 card lists only auto and none: do not rely on forcing a tool there |
tools may also hold a cachePoint; toolSpec.strict=True |
Caching below; strict is the structured-output switch for tools |
Guardrails do not inspect toolUse.input, toolResult or tool definitions |
Validate tool arguments and results yourself (page 03) |
Batch Converse jobs reject toolConfig |
Use the InvokeModel invocation type for batch tool calls |
Tool-calling quality (right tool, right arguments) is an eval problem: see the agent metrics in the DeepEval guide.
Structured Output¶
Two independent mechanisms (usable together). Both are supported on Converse, ConverseStream, InvokeModel, cross-Region profiles and batch, but only on models that list "Structured outputs" (Claude 5.5 yes; Nova 2 Lite no).
import json
import jsonschema
SCHEMA = {
"type": "object",
"properties": {"title": {"type": "string"}, "severity": {"type": "string", "enum": ["low", "medium", "high"]}},
"required": ["title", "severity"],
"additionalProperties": False,
}
resp = client.converse(
modelId=MODEL_ID,
messages=[{"role": "user", "content": [{"text": "Summarize this bug report: ..."}]}],
outputConfig={"textFormat": {"type": "json_schema", "structure": {"jsonSchema": {
"name": "bug_summary",
"description": "A bug summary",
"schema": json.dumps(SCHEMA), # a JSON STRING, not a dict
}}}},
inferenceConfig={"maxTokens": 400},
)
assert resp["stopReason"] == "end_turn" # max_tokens would mean truncated JSON
data = json.loads(resp["output"]["message"]["content"][0]["text"])
jsonschema.validate(data, SCHEMA) # cheap insurance in tests and prod
Strict tool use is the other switch: add "strict": True to the toolSpec so tool names and inputs follow the schema.
| Fact (AWS docs) | Consequence |
|---|---|
Supported schema: JSON Schema 2020-12 subset: basic types, enum, const, anyOf, allOf (limited), internal $ref/$defs, string format (date-time, date, email, uuid, uri, ...), minItems 0 or 1 |
Keep schemas flat and simple |
Not supported: recursion, external $ref, minimum/maximum/multipleOf, minLength/maxLength, additionalProperties other than false |
Unsupported keywords return 400 immediately. Validate ranges and lengths in code after parsing |
| First use of a schema compiles a grammar, "up to a few minutes"; cached 24 hours per account | Warm schemas in a deploy step; expect a slow first call in tests |
| Incompatible with citations on Anthropic models | 400 if both enabled |
| Some geographic profiles lack support for a given model | Check the model card for your profile |
| The guarantee covers a completed response | Still check stopReason; a max_tokens cut gives invalid JSON |
Generate schemas from your models instead of hand-writing them (for Pydantic see Pydantic), then strip unsupported keywords.
Images, Documents, Video and Audio¶
msg = [{"role": "user", "content": [
{"text": "Compare the screenshot with the spec."}, # a text block is required with a document
{"image": {"format": "png", "source": {"bytes": png_bytes}}},
{"document": {"format": "pdf", "name": "Spec v2 (draft)", "source": {"bytes": pdf_bytes}}},
]}]
The SDK base64-encodes bytes for you.
| Block | Formats (boto3 enums) | Rules |
|---|---|---|
image |
png, jpeg, gif, webp |
bytes, or s3Location on Nova models only (others reject S3 URIs) |
document |
pdf, csv, doc, docx, xls, xlsx, html, txt, md |
format is required (ValidationException: Document format is required); include a text block in the same message; name allows only alphanumerics, single spaces, hyphens, parentheses, square brackets. The name is visible to the model: pick a neutral one (prompt injection) |
document + citations: {"enabled": True} |
txt, pdf only |
No s3Location; incompatible with structured output on Anthropic models |
video |
mkv, mov, mp4, webm, flv, mpeg, mpg, wmv, three_gp |
Model-dependent; Nova accepts S3 |
audio |
mp3, opus, wav, aac, flac, mp4, ogg, mkv, mka, x-aac, m4a, mpeg, mpga, pcm, webm |
Model-dependent |
Which modalities a model accepts is on its card (Sonnet 5.5, Haiku 5.5: text and image in, text out; Nova 2 Lite: text, image, video in). For size and count limits, check Service Quotas and the model card. Images and documents passed to Converse are not stored by Bedrock; with invocation logging and image delivery enabled they are written to S3 (page 04).
Prompt Caching¶
Caching reuses a repeated prefix (tools, then system, then messages) so repeated calls cost less and start faster.
| Implicit | Explicit | |
|---|---|---|
| How | Automatic, best effort, no request change | You add cachePoint blocks |
| Guarantee | None | Control over what is eligible; still no hit guarantee |
| Where | Nova text prompts; Anthropic models | Models that list "Explicit prompt caching" |
long_prefix = open("support_policy.md").read() # must exceed the model's minimum
resp = client.converse(
modelId=MODEL_ID,
system=[{"text": long_prefix}, {"cachePoint": {"type": "default", "ttl": "1h"}}], # ttl omitted -> 5 minutes
messages=[{"role": "user", "content": [{"text": "Question 7?"}]}],
)
u = resp["usage"]
total_input = u["inputTokens"] + u.get("cacheReadInputTokens", 0) + u.get("cacheWriteInputTokens", 0)
A cache hit shows up in usage as, for example, cacheReadInputTokens: 3000 and cacheDetails: [{"ttl": "1h", "inputTokens": 3000}].
| Model | Min tokens / checkpoint | Max checkpoints | TTL | Fields |
|---|---|---|---|---|
| Claude Haiku 5.5, Sonnet 5.5, Opus 5.5 | 512 | 4 | 5 min, 1 h | system, messages, tools |
| Claude Sonnet 5, Sonnet 4.6, Sonnet 4.5 | 1,024 | 4 | 5 min, 1 h | same |
| Claude Haiku 4.5, Opus 4.5/4.6/4.7 | 4,096 | 4 | 5 min, 1 h | same |
| Nova 2 Lite, Pro, Lite, Micro | 1K (max 20K cached) | 4 | 5 min | system, messages |
Rules:
- The minimum applies to the cumulative prefix before each checkpoint. Below it the call still succeeds but nothing is cached: check
cacheWriteInputTokensin tests. - Order is
tools→system→messages; changing an earlier section invalidates later ones. Put stable content first, dynamic content last. - A single checkpoint at the end of the static part is enough for Claude: Bedrock looks back about 20 content blocks for an earlier match.
- A
ttlon a model that supports only 5 minutes can raiseValidationException: omit it. Mix 1-hour and 5-minute checkpoints only with the longer TTL first. - The TTL refreshes on each hit. Prompts used more often than every 5 minutes: keep 5 minutes (refresh is free). Use 1 hour for gaps up to an hour, or to avoid quota use (cache reads do not count toward TPM).
- Cache writes count toward TPM and may bill higher than plain input; reads bill at a discount. Not supported in batch inference.
- Adaptive-thinking requests keep message cache breakpoints; switching between
adaptiveandenabled/disabledthinking breaks them.
Thinking (Reasoning) Models¶
For Claude 5.5 models adaptive thinking is on by default (Sonnet 5.5; Haiku 5.5 "can be turned off"), with effort levels low, medium, high, xhigh, max (defaults: high on Sonnet 5.5, medium on Haiku 5.5). Thinking tokens bill as output and count against maxTokens. In Converse, provider fields go in additionalModelRequestFields (the AWS example uses the Opus 4.6 ID; the field names are the same for the 5.x models):
resp = client.converse(
modelId=MODEL_ID,
messages=[{"role": "user", "content": [{"text": "Why is the sum of two even numbers even?"}]}],
inferenceConfig={"maxTokens": 4000}, # leave room for thinking + answer
additionalModelRequestFields={
"thinking": {"type": "adaptive"}, # {"type": "disabled"} turns it off where allowed
"output_config": {"effort": "low"}, # effort is a SIBLING of thinking, never inside it
},
)
- Response
contentcontainsreasoningContentblocks. With the default display (omitted) the block has empty text and asignature; setthinking.displaytosummarizedfor readable text (not guaranteed on every model). Pass the assistant message back unchanged in later turns, including in a tool loop (the code above does). thinking: {"type": "enabled", "budget_tokens": N}is not supported on Sonnet 5 (AWS:ValidationException) or Haiku 5.5. For Sonnet 5.5, useadaptive. On Sonnet 5, only explicitdisabledturns thinking off; omitting the field leaves it on.- A workload migrated from an older Sonnet without thinking changes behaviour and cost until you set it. Test
usage.outputTokensafter the migration. - botocore also has a top-level
outputConfig.effortstring onConverse; this guide uses the documentedadditionalModelRequestFieldsroute. - Nova 2 Lite has reasoning off by default, enabled with
reasoningConfig(effortlow,medium,high); see the Nova user guide for the request syntax. reply_textmust skip non-text blocks (page 01), so areasoningContentblock is ignored.
Errors and Retries¶
Generic botocore retry configuration is in AWS Foundations. The Bedrock-specific part is which errors botocore retries. With Config(retries={"mode": "standard", "total_max_attempts": 4}), botocore behaves as follows:
| Exception | HTTP | converse in botocore model |
Result in standard mode |
|---|---|---|---|
ThrottlingException |
429 | yes | retried; after 4 attempts raises with MaxAttemptsReached: True |
ServiceUnavailableException |
503 | yes | retried |
InternalServerException |
500 | yes | retried |
ModelNotReadyException |
429 | yes | retried (modeled retryable trait) |
ModelTimeoutException |
408 | yes | not retried: raised after 1 request |
ValidationException |
400 | yes | not retried; fix the request |
AccessDeniedException |
403 | yes | not retried; permissions, Marketplace, FTU form |
ServiceQuotaExceededException |
400 | not in converse's error list (it is for InvokeModel) |
not retried; a service quota, not a rate |
ModelErrorException |
424 | yes | the model failed; not retried |
ResourceNotFoundException |
404 | yes | wrong model ID/Region/profile |
Policy:
from botocore.config import Config
from botocore.exceptions import ClientError
cfg = Config(retries={"mode": "standard", "total_max_attempts": 4}, read_timeout=120, connect_timeout=5, tcp_keepalive=True)
RETRY_AT_APP_LEVEL = {"ModelTimeoutException"} # botocore does not retry this one
def call_with_one_more_try(fn):
try:
return fn()
except ClientError as exc:
if exc.response["Error"]["Code"] in RETRY_AT_APP_LEVEL:
return fn() # at most once: a long prompt will just time out again
raise
- AWS guidance: bounded retries with exponential backoff and jitter; honour
Retry-Afterif present; do not ramp traffic during sustained 503. Throttling (429) means your quota; 503 means service capacity, not your quota. - 429/503 persisting: cut concurrency, queue work (SQS), try a cross-Region profile, consider the flex or priority tier. The
529 overloaded_errorappears on the Anthropic Messages protocol, not in Converse. - Retrying a call that already streamed text re-sends a whole new request: it is not idempotent, and you pay twice.
- Use a circuit breaker and fallback model for outages; a fallback must be tested on the same prompts because tool and thinking support differ per model.
- Throttled requests show up in the CloudWatch metric
InvocationThrottles, not inInvocations, and how many you see depends on your SDK retry settings.
Quick Rules¶
- Assemble streams by block index, parse tool JSON only at
contentBlockStop, and read usage from the last event. - Expect
EventStreamErrormid-stream: it is not retried and partial text may already be on screen. - Bound the tool loop (
max_turns), return all results of a turn in one user message, and report tool errors withstatus: "error". - Append the assistant message unchanged (reasoning blocks, tool ids).
- Schema goes as a JSON string in
outputConfig, as an object intoolSpec.inputSchema. Validate ranges yourself and checkstopReason. - Put stable content first and check
cacheWriteInputTokensto prove caching works; size prompts above the model's minimum. - Set thinking explicitly (
adaptivewith an effort, ordisabled) and budgetmaxTokensfor it. - Know your retry map: botocore retries 429, 500 and 503 but not
ModelTimeoutException; setread_timeoutfor long outputs.
See also¶
- Amazon Bedrock
- 01 Models, Access, Regions & the Converse API
- 03 Guardrails, Knowledge Bases, Agents & Batch
- 04 Testing & Production
- AWS Foundations: Clients, Config, Retries & Errors
- Resilience: Timeouts, Fallbacks & Circuit Breakers
- LiteLLM: Tools & Structured Output
- DeepEval: AI Agent Metrics
- Pydantic