,

Amazon Bedrock Converse API vs InvokeModel, and When to Use Each (AWS Gen AI Series, Part 11)

InvokeModel hands you each model’s native JSON. The Converse API gives one request and one response shape for every chat model on Bedrock. Here is when to use each, and where InvokeModel is still the only door.

AWS Gen AI Series · Part 11 of 30

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.

Key takeaways: Converse is one API for every Bedrock model that supports messages. Same request, same response, so switching models is a one line change. It carries multi turn history, system prompts, tool use, streaming through ConverseStream, and image or document input in a single shape. InvokeModel is the lower level door: you send the model’s native JSON body and parse its native response, which you still need for embeddings, image generation, and any model or native field Converse does not expose. Neither costs more; the inference price is identical. Default to Converse for chat and agents, and drop to InvokeModel where the model type or a native field forces it.

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))
One prompt, two ways to send itInvokeModel needs a body per model. Converse needs one.INVOKEMODELYour apppicks a modelModel-native bodyClaude, Nova, Llama, MistralChosen modelnative responseCONVERSEYour apppicks a modelOne messages shapesame for all modelsAny chat modelone response shapeSame prompt. InvokeModel forks by model. Converse does not.
InvokeModel pushes provider differences into your code. Converse hides them behind one contract.
Prerequisites: You have made at least one Bedrock inference call across Parts 1 to 10, you can read a short Python boto3 snippet, and you know what a JSON request body is. No prior Converse experience needed. Knowledge Bases and Agents come in later parts; here we stay at the raw inference call.

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.

CapabilityInvokeModelConverse
Request bodyModel-native, differs per providerOne shape for all message models
Response bodyModel-native, differs per provideroutput.message.content, same for all
Token usage fieldProvider-specific namesusage.inputTokens, outputTokens, totalTokens
Multi turn historyYou assemble it in native formatmessages array, model-agnostic
Tool use, function callingModel-native fieldstoolConfig, same shape
StreamingInvokeModelWithResponseStream, native chunksConverseStream, unified events
Image or document inputModel-nativecontent blocks: text, image, document
GuardrailsApplyGuardrail or manual wiringguardrailConfig inline
Embeddings, image generationSupportedNot 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.

In practice: On real routers I keep a single 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'])
Expected output: the one line summary, then three integers such as 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.

Tokens a Converse call reportsOne usage block, same fields for every model0600120018001240input386output1626total
The three counts your cost code reads. Same field names whatever model produced them.

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 typeExamplesConverse?Use
Text and chatClaude, Nova micro/lite/pro, Llama, Mistral, Cohere CommandYesConverse or ConverseStream
Text embeddingsTitan Text Embeddings, Cohere EmbedNoInvokeModel
Image generationTitan Image Generator, Nova Canvas, StabilityNoInvokeModel
Video generationNova ReelNoInvokeModel, async job

Chat models take Converse. Everything that is not a chat turn stays on InvokeModel with its native body.

Gotcha: A common migration bug is pointing your one Converse wrapper at an embeddings model because it sits in the same catalog. Converse rejects it with a 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'])
Expected output: the text streams token by token, then the two usage integers print on a new line. Failure mode: an 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.
Request formats you maintainOne per provider family with InvokeModel, one total with Converse02465InvokeModel1Converse
Five body and response shapes collapse to one. That is the maintenance win, plotted.

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.

AWS Gen AI Series · Part 11 of 30
« Previous: Part 10  |  Guide  |  Next: Part 12 »

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

About The Author


Discover more from Journal of Intelligent Infrastructure – By Dr Pranay Jha

Subscribe to get the latest posts sent to your email.

Leave a Reply

Your email address will not be published. Required fields are marked *

Architect’s Toolkit

About the Author

Dr. Pranay Jha is a Cloud and AI Consultant with 18+ years of experience in hybrid cloud, virtualization, and enterprise infrastructure transformation. He specializes in VMware technologies, multi-cloud strategy, and Generative AI solutions. He holds a PhD in Computer Applications with research focused on Cloud and AI, has published multiple research papers, and has been a VMware vExpert since 2016 and a VMUG Community Leader.

Discover more from Journal of Intelligent Infrastructure - By Dr Pranay Jha

Subscribe now to keep reading and get access to the full archive.

Continue reading