Send the same sentence to two Bedrock models with InvokeModel and you write two different request bodies. Claude wants a messages array and an anthropic_version field. Amazon Nova wants its own inferenceConfig block. Meta Llama wants a single prompt string with special tokens. Same prompt, three payloads, three response shapes to parse. Swap the model and your code breaks in a spot that has nothing to do with the model being better or worse.
That per model plumbing is the problem the Converse API was built to remove. It gives you one request shape and one response shape for every chat model on Bedrock, so the model becomes a string you change, not a rewrite you schedule. This part is about when that trade pays off, and the handful of cases where you still reach past Converse to InvokeModel on purpose.
Why one prompt needs five different request bodies
InvokeModel is the original Bedrock inference call. You hand it a model ID and a raw JSON body, and it hands back a raw JSON response, both in whatever shape the model provider defined. That design is honest about what Bedrock is under the hood, a gateway in front of many providers, but it pushes the differences straight into your code. Anthropic defines one contract, Amazon Nova another, Meta Llama a third, Mistral and Cohere their own. The field that carries your text, the field that limits output length, the field that reports token usage: all of them move around from provider to provider.
Build a router that supports five model families on InvokeModel and you are maintaining five body builders and five response parsers. Each one knows a provider’s quirks. Add a model and you write another pair. That is the cost people do not see when they start with one model and grow into a fleet. Here is the divergence in the concrete, two providers, same intent, different bodies.
import json, boto3
rt = boto3.client('bedrock-runtime', region_name='us-east-1')
# Claude wants anthropic_version and a messages array
claude_body = {
'anthropic_version': 'bedrock-2023-05-31',
'max_tokens': 300,
'messages': [{'role': 'user', 'content': 'Summarize this ticket.'}],
}
# Nova wants a different schema with its own inferenceConfig
nova_body = {
'messages': [{'role': 'user', 'content': [{'text': 'Summarize this ticket.'}]}],
'inferenceConfig': {'maxTokens': 300},
}
# Two bodies, two response shapes to parse, for the same request
rt.invoke_model(modelId='anthropic.claude-sonnet-4-6-v1',
body=json.dumps(claude_body))
What the Converse API standardizes
Converse defines a fixed vocabulary and makes every message model speak it. Messages carry a role of user or assistant and a list of content blocks. System prompts sit in their own system list rather than being smuggled into the first user turn. Sampling settings live in inferenceConfig with maxTokens, temperature, and topP. The response always arrives as output.message.content with a stopReason and a usage block whose fields never change. Write the parser once and it holds for Claude, Nova, Llama, and the rest.
| Capability | InvokeModel | Converse |
|---|---|---|
| Request body | Model-native, differs per provider | One shape for all message models |
| Response body | Model-native, differs per provider | output.message.content, same for all |
| Token usage field | Provider-specific names | usage.inputTokens, outputTokens, totalTokens |
| Multi turn history | You assemble it in native format | messages array, model-agnostic |
| Tool use, function calling | Model-native fields | toolConfig, same shape |
| Streaming | InvokeModelWithResponseStream, native chunks | ConverseStream, unified events |
| Image or document input | Model-native | content blocks: text, image, document |
| Guardrails | ApplyGuardrail or manual wiring | guardrailConfig inline |
| Embeddings, image generation | Supported | Not supported, use InvokeModel |
Both APIs run the same model at the same token price. The table is about the shape of your code, not the size of your bill.
converse() wrapper and pass modelId from config. Adding a chat model becomes a config entry and a price check, not a code change. The one thing that does not move for free is a model-specific parameter, which rides in additionalModelRequestFields. I come back to that escape hatch at the end.A Converse call, end to end
A minimal Converse call has three moving parts: the model ID, a system prompt, and the messages. Everything else has a sane default. The response comes back in the shape you can rely on across models. Read the text from output.message.content and the counts from usage.
import boto3
rt = boto3.client('bedrock-runtime', region_name='us-east-1')
resp = rt.converse(
modelId='amazon.nova-pro-v1:0',
system=[{'text': 'You are a concise support assistant.'}],
messages=[{'role': 'user',
'content': [{'text': 'Summarize this ticket in one line.'}]}],
inferenceConfig={'maxTokens': 300, 'temperature': 0.2},
)
msg = resp['output']['message']['content'][0]['text']
usage = resp['usage']
print(msg)
print(usage['inputTokens'], usage['outputTokens'], usage['totalTokens'])
1240 386 1626. Change modelId to anthropic.claude-sonnet-4-6-v1 and nothing else in the code moves. Failure mode: a ValidationException naming the model means you pointed Converse at a model that does not support messages, such as an embeddings model. Route that call to invoke_model instead.The stopReason tells you why generation ended, and it is worth branching on. Common values are end_turn for a normal finish, max_tokens when the model hit your limit, tool_use when it wants to call a tool, stop_sequence for a configured stop, and guardrail_intervened when a guardrail blocked the content. Handle max_tokens and tool_use at minimum, because both mean the answer you got is not the final answer.
Token accounting changes when you switch
Here is the migration trap that bites quietly. With InvokeModel you parse provider-specific usage fields. Anthropic returns usage.input_tokens and usage.output_tokens. Amazon Titan returns inputTextTokenCount. Move that integration to Converse and the counts now live in usage.inputTokens, usage.outputTokens, and usage.totalTokens. Your old cost and metrics code that read the native field names does not error. It reads nothing, logs zero, and your spend dashboards go flat while the real bill keeps climbing. Rewire the token code the same day you switch.
Worked example
One Converse call to Nova Pro summarizing a support ticket. The response reports inputTokens 1,240, outputTokens 386, totalTokens 1,626. At Nova Pro list rates near 0.0008 dollars per 1,000 input tokens and 0.0032 dollars per 1,000 output tokens [VERIFY current rate], that is about 0.00099 for input plus 0.00124 for output, roughly 0.0022 dollars for the call.
The fraction of a cent is not the point. The point is that the same three fields come back for Claude, Llama, or Mistral, so one cost function covers every model. On InvokeModel you would write a different extractor for each.
Which models can Converse actually talk to?
Converse works with models that support the messages format, which today is every text and chat model in the catalog. It does not cover embeddings models or image and video generation models, because there is no chat turn to model in an embedding vector or a generated picture. For those you stay on InvokeModel with the native body. Before you assume a model works with Converse, check the API compatibility page, because the catalog moves.
| Model type | Examples | Converse? | Use |
|---|---|---|---|
| Text and chat | Claude, Nova micro/lite/pro, Llama, Mistral, Cohere Command | Yes | Converse or ConverseStream |
| Text embeddings | Titan Text Embeddings, Cohere Embed | No | InvokeModel |
| Image generation | Titan Image Generator, Nova Canvas, Stability | No | InvokeModel |
| Video generation | Nova Reel | No | InvokeModel, async job |
Chat models take Converse. Everything that is not a chat turn stays on InvokeModel with its native body.
ValidationException about an unsupported model. Keep a separate InvokeModel path for embeddings and image calls; do not try to force them through Converse, and do not catch the error and silently drop the request.Tool use and streaming in one shape
Tool use, also called function calling, is the same toolConfig block for every model that supports tools. You describe each tool with a name, a description, and a JSON input schema, and when the model wants to call one it returns a toolUse content block with a stopReason of tool_use. You run the tool, append the result as a toolResult block, and call Converse again. That loop is identical whether the model behind it is Claude or Nova. On InvokeModel the tool fields differ by provider, so a model swap means rewriting the tool handling too.
Streaming follows the same idea. converse_stream returns one event stream you iterate, with the text arriving in contentBlockDelta events and the token counts in a final metadata event. Compare that to InvokeModelWithResponseStream, where the chunk shape is provider-specific and you parse a different envelope per model.
resp = rt.converse_stream(
modelId='anthropic.claude-sonnet-4-6-v1',
messages=[{'role': 'user',
'content': [{'text': 'Write a two line status update.'}]}],
inferenceConfig={'maxTokens': 200},
)
for event in resp['stream']:
if 'contentBlockDelta' in event:
print(event['contentBlockDelta']['delta']['text'], end='')
elif 'metadata' in event:
u = event['metadata']['usage']
print()
print(u['inputTokens'], u['outputTokens'])
AccessDeniedException on bedrock:InvokeModelWithResponseStream means the role can call Converse but not stream. ConverseStream maps to that streaming permission, so grant it alongside bedrock:InvokeModel, which is what plain Converse maps to.Guardrails and the model-specific escape hatch
Two more things move in your favor with Converse. Guardrails attach inline through a guardrailConfig parameter on the call, so the same guardrail wires onto any model with no per provider handling. With InvokeModel you either call ApplyGuardrail separately or wire the guardrail fields into each native body. Guardrails get their own part later in the series; for now the point is that Converse makes attaching one uniform.
The second is the reason the unified shape rarely boxes you in. When a model has a native parameter Converse does not name, such as Anthropic top_k or an extended thinking setting, you pass it in additionalModelRequestFields, and you pull native values back out through additionalModelResponseFieldPaths. You keep the unified request, the unified response parsing, and the token block, while still reaching a knob that only one provider exposes. That escape hatch is why a model swap almost never forces you back to raw InvokeModel for a chat model.
My take
I treat additionalModelRequestFields as the feature that keeps me on Converse. If a model has a knob the unified API does not surface, I pass it there and keep one response parser. The only chat case where I still reach for InvokeModel is a legacy integration that already parses native responses and is not worth rewriting this quarter. Everything new goes on Converse from the first line. A concrete case, a model exposes top_k while the Converse inference parameters do not, so you pass it through additionalModelRequestFields and keep the same response parser you use for every other model. That single field is why I stay on Converse instead of maintaining a native code path per model.
Keeping multi turn history straight
The clearest reason Converse feels different from InvokeModel shows up on the second turn. A model holds no memory between calls; a conversation is only as long as the messages you resend. With InvokeModel you rebuild that history in each provider’s native format, which is one more place a model swap can break. Converse keeps the history in a single list of messages you grow as the exchange goes, and the same list works for any chat model behind it.
The pattern is small and worth getting exactly right. Send the user turn, take the assistant reply out of output.message, append that object to your list unchanged, then append the next user turn and call again. Because you append the exact assistant message the API returned, tool calls and image blocks survive across turns without you reshaping them by hand.
history = [{'role': 'user',
'content': [{'text': 'What Region is my Bedrock call served from?'}]}]
r1 = rt.converse(modelId='amazon.nova-pro-v1:0', messages=history)
history.append(r1['output']['message']) # keep the assistant turn verbatim
history.append({'role': 'user', 'content': [{'text': 'And how do I pin it?'}]})
r2 = rt.converse(modelId='amazon.nova-pro-v1:0', messages=history)
print(r2['output']['message']['content'][0]['text'])
One caution follows straight from the token section. Every turn resends the whole history, so inputTokens grows with the conversation, and in a long chat that growth, not the model, is the real cost driver. Trim or summarize old turns once the history gets long, and watch the input count climb turn over turn. Managing that window is its own design decision, and it is where the token accounting from earlier turns into a running budget rather than a single number.
Which API I ship, and when I do not
My recommendation is blunt. Default to Converse for every chat, agent, tool, and image or document input path. Reach for InvokeModel only in three cases: embeddings, image or video generation, or a legacy integration you are not ready to migrate. When a chat model has a native parameter you need, pass it through additionalModelRequestFields instead of abandoning Converse. And when you migrate an existing InvokeModel call, rewire the token and cost code to the usage block the same day, because the old field names go quiet without ever throwing an error.
Next in the series I move from the raw inference call to grounding it in your own data, with Bedrock Knowledge Bases and retrieval augmented generation, where a Converse call becomes the generation step at the end of a retrieval pipeline. The same unified idea shows up on other clouds too, so if you also run there, the Azure Gen AI guide covers its equivalent unified chat surface. Before you read on, take one invoke_model call in your codebase, swap it to converse(), and confirm the usage block prints three integers. If it does, you have removed one provider-specific parser for good.
Related reading: the vendor-neutral GenAI guide explains tool use and streaming at the concept level, without the Bedrock specifics.
References
• Inference using the Converse API, Amazon Bedrock User Guide
• Converse API reference
• API compatibility by models
• InvokeModel API reference


DrJha