Get ahead
VMware offers training and certification to turbo-charge your progress.
Learn moreSpring AI: Message Parts Doc | Upgrade Notes | OpenAI Responses Doc | Session Doc
Reasoning models do more than think before they answer. They hand part of that thinking back to you and expect to get it back on the next request. Claude signs its thinking blocks. Gemini attaches thought signatures to its function calls. OpenAI's Responses API returns encrypted reasoning items. If you drop, reorder or edit them, the result ranges from an HTTP 400 to a model that quietly loses its earlier reasoning and starts over.
Until now, a Spring AI message was a piece of text with side lists of tool calls and media. There was no place for that data, let alone its order. Spring AI 2.1 changes the data model itself: every message now holds its content as an ordered list of message parts. A part is one piece of a response, such as a run of text, a block of reasoning or a tool call. Providers call these pieces content blocks (Anthropic, Bedrock), parts (Gemini), items (OpenAI Responses) or chunks (Mistral), and Spring AI maps them all to message parts.
With the 2.1.0-RC1 release candidate, the major chat models produce and consume parts: OpenAI Responses, Anthropic, Google GenAI, OpenAI Chat Completions, Ollama, Bedrock Converse, DeepSeek and Mistral AI. The release also adds Session memory, which persists them. This post covers the new abstraction, why the Responses API needed it, and what each chat model gains. It comes down to three rules: keep the order, replay what you were given verbatim, and persist the parts, not the text.
Here is what Claude, with thinking enabled, returns in a single tool-calling round:
thinking "The user wants the weather in Paris. I should call get_weather..." signature: "EqQBCgIYAhIM..."
text "Let me check that for you."
tool_use get_weather {"location": "Paris"}
When you send the tool result, Anthropic wants this turn back exactly as it was: same blocks, same order, signature included. Before 2.1, AssistantMessage could not do that:
JdbcChatMemoryRepository stores only the text and the message type.AbstractMessage now holds a parts: List<MessagePart>, shared by every message type:
MessagePart is a sealed interface with six record implementations:
| Part | Holds | Used for |
|---|---|---|
TextPart |
a run of text | answers, prompts |
ReasoningPart |
reasoning text, an optional summary, an optional payload | Claude thinking, Gemini thoughts, DeepSeek reasoning_content |
ToolCallPart |
an AssistantMessage.ToolCall, an optional payload |
function calls |
ToolResultPart |
a tool result whose content is a list of other MessageParts |
tool results carrying more than text |
MediaPart |
a Media: image, audio, video, document |
multimodal input and output |
UnknownPart |
the raw JSON of a block Spring AI does not model | server tool calls, citations, code execution |
Adding MessagePart achieves:
OpaquePayload(provider, kind, data), such as an Anthropic signature, a Gemini thought_signature or an OpenAI encrypted_content. The ChatModel that produced it sends it back unchanged, and every other chat model skips it. This keeps a history safe to move between providers.ChatModel does not understand becomes an UnknownPart and is replayed as-is to the provider that produced it.The change is backward compatible. Constructors and builder methods keep working and produce parts in the order 2.0 used: text, tool calls, media.
getText(), getToolCalls() and getMedia() are now views over the parts, next to the new getReasoning(), and getParts() returns everything in order.
Code that reads reasoning is what most likely needs an update, since each chat model exposed it differently before 2.1:
ChatResponse response = chatModel.call(prompt);
// Before 2.1: each chat model exposed reasoning its own way
List<String> thoughts = response.getResults().stream() // Google GenAI
.filter(g -> Boolean.TRUE.equals(g.getOutput().getMetadata().get("isThought")))
.map(g -> g.getOutput().getText())
.toList();
String reasoning = ((DeepSeekAssistantMessage) response.getResult().getOutput())
.getReasoningContent(); // DeepSeek
// 2.1: one accessor for every chat model
List<String> thoughts = response.getResult().getOutput().getReasoning().stream()
.map(ReasoningPart::text)
.toList();
String answer = response.getResult().getOutput().getText(); // unchanged
The upgrade notes list the remaining changes per chat model. When streaming, each part is stamped with its content block index and a partial flag, and MessageAggregator merges the deltas.
The aggregated message equals the one a blocking call returns, signature included.
Spring AI implements two OpenAI chat model endpoints. OpenAiChatModel uses Chat Completions, OpenAI's original chat API, which many other servers also implement.
OpenAiResponsesChatModel, new in 2.1, uses the Responses API, OpenAI's newer API for reasoning models and agents, in its stateless mode.
The Responses API does not return a message. Each call returns an output array of items: reasoning, function_call and message, plus items
for the tools OpenAI runs itself, such as web search. Each reasoning item carries its own id and encrypted_content.
OpenAiResponsesChatModel is stateless by design: it does not keep the conversation on OpenAI's side, but replays all of it on every call, so memory, advisors and
RAG work exactly as they do with OpenAiChatModel. For a reasoning model, the replay works well only if it includes the model's earlier reasoning. OpenAI's reasoning guide asks
for the reasoning items, function calls and function call outputs since the last user message.
Before 2.1, AssistantMessage could not hold these items faithfully:
encrypted_content), and both have to be sent back with that item. A message had only one metadata map for the whole response.With parts, the mapping is one to one and keeps the order of the output array:
| Responses API item | Message part |
|---|---|
reasoning |
ReasoningPart with the summary, OpaquePayload("openai.responses", "encrypted_content", …) and the item id |
function_call |
ToolCallPart with the item id |
message |
TextPart |
image_generation_call |
MediaPart |
| web search, file search, code interpreter, MCP, any other item | UnknownPart with the raw JSON |
On the next request, the chat model rebuilds the input items from the parts in the same order. That replay is worth having. OpenAI's now archived cookbook on
better performance from reasoning models reports that including reasoning items "led to about a 3% improvement" on SWE-bench.
Responses is also the endpoint for agents on current OpenAI models.
Starting with GPT-5.4, Chat Completions does not support tool calling with a reasoning effort other than none.
Every other chat model follows the same pattern: map each response block to one part, rebuild the request from the parts in order, and replay only its own payloads. What that buys depends on what each provider demands:
| Chat model | Now replayed | Improvement |
|---|---|---|
| Anthropic | signed and redacted thinking, in block order; server tool blocks | tool turns Claude accepts, reasoning continuity, cache hits |
| Google GenAI | each thought signature on the part that carried it | no errors on Gemini 3+ function calls, full reasoning quality on other parts |
| OpenAI Chat Completions | reasoning_content |
continuity on vLLM, gpt-oss, Kimi and other compatible servers |
| Ollama | the thinking field |
continuity in gpt-oss and qwen3 tool loops |
| Bedrock Converse | signed, redacted and unsigned reasoning, also when streaming | streaming tool loops with extended thinking work |
| DeepSeek | reasoning_content of streamed and restored messages |
no errors in thinking mode with tools |
| Mistral AI | thinking chunks, in chunk order | the output quality Mistral says is lost without them |
Within a tool-use turn, Claude requires its thinking blocks back, and you "can't rearrange, edit, or partially drop them": modified blocks are rejected with an HTTP 400. Before 2.1, Spring AI rebuilt the turn as text, then thinking, then tool use, whatever order Claude had used. It also dropped server tool blocks, and the streamed thinking never fully reached the aggregated message. Now every block is one part, replayed in order with its signature. Anthropic names two more gains: preserved thinking "lets Claude continue reasoning from where it left off", and preserved thinking blocks "enable cache hits during tool use". Claude Opus 4.5 and later, and Sonnet 4.6 and later, also keep earlier turns' thinking in context, which is where Session memory comes in.
Google's thought signatures guide says to return each signature "in the exact part where it was received". On Gemini 3 and later, a missing signature on a function call fails the request "with a 400 error". On other parts, "performance may degrade". Spring AI previously collected all signatures in one list and reattached them to function calls by position. It also dropped the thoughts and text in front of a function call from the tool loop. Now each signature travels on its own part, and code execution and server-side tool parts are sent back as received.
OpenAI's own Chat Completions API returns no reasoning text, so Responses should be used for OpenAI reasoning models. OpenAiChatModel serves the
compatible servers that do return reasoning_content, such as vLLM, gpt-oss deployments and Kimi. OpenAI's harmony guide asks you
to pass gpt-oss's chain of thought back during tool calling. That replay already existed in 2.0.x, through a metadata key. It now rides on parts,
so it survives persistence.
Reasoning signed by another provider is never forwarded, and for servers that reject the field there is replayReasoningContent(false).
Before 2.1, thinking lived only in response metadata and was never sent back. When streaming, only its last fragment survived.
Now it is replayed in the assistant message's thinking field, as Ollama's tool calling docs ask:
"return those fields together with any tool results in the follow-up request". The gain depends on the model.
The gpt-oss and qwen3 templates use the reasoning of the current tool loop.
Bedrock applies the Claude rule: "During tool use, you must pass thinking blocks back to the API for the last assistant message".
BedrockProxyChatModel previously dropped reasoning from streamed responses. Now a response is a single generation of parts in block order,
streams carry the reasoning, the signature and redacted reasoning, and unsigned reasoning from gpt-oss is replayed too.
In thinking mode, a request that carries tools must pass back the reasoning_content of earlier assistant messages, or the
API returns a 400. Before 2.1, Spring AI sent it only for DeepSeekAssistantMessage instances, so streamed responses and messages restored
from memory went out without it. Now any assistant message replays its ReasoningParts, streamed or not, and DeepSeekAssistantMessage
is deprecated.
Mistral's reasoning docs say: "always replay the full assistant message (including ThinkChunk)". They warn that dropping
the reasoning trace "degrades model performance". Before 2.1, MistralAiChatModel never sent thinking back. Now thinking, text, citation
and image chunks become parts and are replayed in chunk order, including between the rounds of a tool loop.
Replay inside one ChatClient call is half the story. Between calls, the history lives in chat memory, and of the ChatMemory repositories only the in-memory one keeps the parts.
With any other, the model reasons from scratch on each restored turn.
Spring AI Session, now part of Spring AI 2.1, closes that gap.
A session is an append-only event log. Each message is stored as its full list of parts, payloads and attributes included, and
the JDBC store keeps them as JSON. JdbcChatMemoryRepository, the JDBC store behind ChatMemory, keeps only the text and drops tool calls and tool results entirely.
Two more properties matter for reasoning models:
SessionMemoryAdvisor runs inside the tool-calling loop, so it stores each round's tool calls and results, not just the final answer. Each round's prompt is rebuilt from the session, so signatures make the round trip through the store.Add the spring-ai-starter-model-session-jdbc starter, register the session advisors and pass a session id on each call:
@Bean
ChatClient chatClient(ChatClient.Builder builder, SessionService sessionService) {
return builder
.defaultAdvisors(SessionAdvisors.builder(sessionService)
.compaction(new TurnCountTrigger(20),
WindowCompactionStrategy.builder().lastTurns(10).build())
.build())
.build();
}
String answer = chatClient.prompt()
.user("Find the cheapest flight to Lisbon next Friday")
.advisors(SessionAdvisors.session("session-42"))
.call()
.content();
With this in place, the gains above carry across calls and restarts. Claude still sees its earlier thinking,
Gemini gets each signature back on the right part, and DeepSeek's thinking-mode rule holds after a redeploy.
ChatMemory and its repositories are deprecated in 2.1 and will be removed in 3.0.0. The migration guide maps one API onto the other.
⚠️ Things to know
One generation per response. Before 2.1, Anthropic, Google GenAI and Bedrock Converse split a response into separate
Generations for thinking, tool calls and text. They now return one per candidate. Read reasoning withgetResult().getOutput().getReasoning().Histories stored before 2.1 have no reasoning parts. Messages that an earlier version wrote to chat memory come back without their reasoning, so it is not replayed.
Cross-provider history is safe, not lossless. Reasoning signed by one provider is never sent to another. When you switch models mid-conversation, the text and tool calls carry over, but the other vendor's reasoning does not.
Keep part attributes. Mistral, and Bedrock for unsigned reasoning, replay reasoning only when the part's
providerattribute names them. If you store history yourself, don't strip attributes.
Message parts turn a Spring AI message from a string with attachments into a faithful record of what the model produced and in what order. The key insights:
OpaquePayload lets each chat model return its own signatures unchanged and skip everyone else's.Spring AI
Related Spring AI articles