#7000·spring-ai

`AnthropicChatModel` makes thinking blocks the primary generation

Author: iuliiasobolevskaCreated Sep 15, 2026Updated Sep 18, 2026
Labelsstatus: waiting-for-triage

Bug description

AnthropicChatModel returns Anthropic thinking and redacted-thinking content blocks as separate Generation entries before the final assistant generation. ChatResponse.getResult() is documented and implemented as the first generation, so callers receive a thinking block instead of the assistant's answer whenever thinking is present.

This also breaks typed ChatClient responses. ChatClient.call().entity(...) converts only ChatResponse.getResult(). If the first generation is a redacted-thinking block, its text is empty, and BeanOutputConverter fails even though a later generation contains valid structured JSON:

tools.jackson.databind.exc.MismatchedInputException:
No content to map due to end-of-input

If the first generation is a visible thinking block, the converter attempts to deserialize the reasoning text instead of the final JSON answer.

The ordering comes from AnthropicChatModel.buildGenerations(...). Thinking generations are added as the provider content blocks are iterated over, and the AnthropicAssistantMessage containing the accumulated text and tool calls is appended after the loop.

Environment

  • Spring AI: 2.0.1 and main at d0aca66909 (2.1.0-SNAPSHOT)
  • Module: spring-ai-anthropic
  • Classes: AnthropicChatModel, ChatResponse, and DefaultChatClient
  • Java: 17+
  • Model: any Anthropic model response containing a thinking or redacted-thinking block before its text block
  • No vector store involved

Steps to reproduce

  1. Configure an AnthropicChatModel with thinking enabled, or use a model that returns adaptive thinking.
  2. Make a request whose response contains a thinking or redacted-thinking block followed by a text block.
  3. Inspect chatResponse.getResult().
  4. Alternatively, call chatClient.prompt().user(...).call().entity(MyRecord.class) when the text block contains valid JSON.

Actual generation order:

0: thinking or redacted-thinking generation
1: final AnthropicAssistantMessage

Consequently, getResult() returns entry 0 and typed conversion reads the wrong content.

Expected behavior

The final AnthropicAssistantMessage should be the primary generation returned by ChatResponse.getResult(). Thinking generations should remain available through ChatResponse.getResults(), and signed thinking content should remain attached to the Anthropic assistant message for tool-call replay.

Expected generation order:

0: final AnthropicAssistantMessage
1..n: thinking or redacted-thinking generations

This preserves access to reasoning metadata, ensures the provider is consistent with the ChatResponse.getResult() contract, and allows ChatClient.entity(...) to deserialize the final answer.

Minimal Complete Reproducible example

Regression test for AnthropicChatModelTests, using that test class's existing mocked SDK client and createMockMessageWithThinkingAndText(...) helper:

java
@Test
void thinkingResponseReturnsFinalAssistantGenerationAsPrimary() {
	Message mockResponse = createMockMessageWithThinkingAndText(
			"thinking text",
			"thinking-signature",
			"{\"answer\":\"done\"}");
	given(this.messageService.create(any(MessageCreateParams.class))).willReturn(mockResponse);

	ChatResponse response = this.chatModel.call(new Prompt("Explain it"));

	assertThat(response.getResults()).hasSize(2);
	assertThat(response.getResult().getOutput().getText()).isEqualTo("{\"answer\":\"done\"}");
	assertThat(response.getResults().get(1).getOutput().getText()).isEqualTo("thinking text");
	assertThat(response.getResults().get(1).getOutput().getMetadata())
		.containsEntry("signature", "thinking-signature");
}

On the current implementation, the first text assertion fails because getResult() contains thinking text. The corresponding redacted-thinking case returns an empty string and produces the MismatchedInputException above when used through ChatClient.entity(...).

Suggested fix

Make the accumulated final assistant generation the first result, while preserving the individual thinking generations that follow. For example, buildGenerations(...) can insert the final AnthropicAssistantMessage at index zero instead of appending it:

java
generations.add(0,
		new Generation(buildAssistantMessage(textContent.toString(), toolCalls, thinkingContents),
				generationMetadata));

The existing thinking and redacted-thinking replay tests should continue to verify that replayed provider request blocks retain their required order: thinking first, then tool use. That request-block order is independent of which Generation is primary in the Spring AI response.

Source: spring-projects/spring-ai