rawReasoning(
+ @Nonnull final OrchestrationChatCompletionDelta delta) {
+ final var choices = delta.getFinalResult().getChoices();
+ if (!choices.isEmpty() && choices.get(0).getIndex() == 0) {
+ return choices.get(0).getDelta().getReasoningContent();
+ }
+ return List.of();
+ }
+}
diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/ReasoningEffort.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/ReasoningEffort.java
new file mode 100644
index 000000000..735118c00
--- /dev/null
+++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/ReasoningEffort.java
@@ -0,0 +1,41 @@
+package com.sap.ai.sdk.orchestration;
+
+import javax.annotation.Nonnull;
+
+/**
+ * Reasoning effort levels for the harmonized {@code reasoning_effort} model parameter.
+ *
+ * Not all reasoning-capable models support the full range of values. Where a model does not
+ * support a given effort, orchestration transparently maps it to the closest equivalent.
+ *
+ * @see SAP AI Core:
+ * Orchestration - Reasoning
+ */
+public enum ReasoningEffort {
+ /** Minimal reasoning effort. */
+ MINIMAL("minimal"),
+ /** Low reasoning effort. */
+ LOW("low"),
+ /** Medium reasoning effort. */
+ MEDIUM("medium"),
+ /** High reasoning effort. */
+ HIGH("high"),
+ /** Disable reasoning when the model supports doing so. */
+ NONE("none");
+
+ private final String value;
+
+ ReasoningEffort(@Nonnull final String value) {
+ this.value = value;
+ }
+
+ /**
+ * Wire string sent as the value of the {@code reasoning_effort} model parameter.
+ *
+ * @return the wire value.
+ */
+ @Nonnull
+ public String getValue() {
+ return value;
+ }
+}
diff --git a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationReasoningContentTest.java b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationReasoningContentTest.java
new file mode 100644
index 000000000..d912d8124
--- /dev/null
+++ b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationReasoningContentTest.java
@@ -0,0 +1,190 @@
+package com.sap.ai.sdk.orchestration;
+
+import static com.sap.ai.sdk.orchestration.OrchestrationJacksonConfiguration.getOrchestrationObjectMapper;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.sap.ai.sdk.orchestration.model.AssistantChatMessage;
+import com.sap.ai.sdk.orchestration.model.CompletionPostResponse;
+import com.sap.ai.sdk.orchestration.model.CompletionPostResponseStreaming;
+import com.sap.ai.sdk.orchestration.model.ReasoningBlock;
+import java.util.List;
+import javax.annotation.Nonnull;
+import lombok.val;
+import org.junit.jupiter.api.Test;
+
+/** Unit tests for the reasoning-content SDK surface */
+class OrchestrationReasoningContentTest {
+
+ private static final String SYNC_RESPONSE_TEMPLATE =
+ """
+ {
+ "intermediate_results": {"templating": []},
+ "final_result": {
+ "choices": [
+ {
+ "message": {"role": "assistant", "content": "The answer is 42."%s}
+ }
+ ]
+ }
+ }
+ """;
+
+ private static final String STREAM_CHUNK_TEMPLATE =
+ """
+ {
+ "final_result": {
+ "choices": [
+ {
+ "index": %d,
+ "delta": {"role": "assistant", "content": "hello"%s}
+ }
+ ]
+ }
+ }
+ """;
+
+ @Test
+ void withReasoningEffortSetsParam() {
+ val model = OrchestrationAiModel.CLAUDE_4_5_SONNET.withReasoningEffort(ReasoningEffort.MEDIUM);
+
+ assertThat(model.getParams()).containsEntry("reasoning_effort", "medium");
+ assertThat(model.createConfig().getParams()).containsEntry("reasoning_effort", "medium");
+ }
+
+ @Test
+ void assistantMessageSerializesReasoningContentVerbatim() {
+ // Reasoning content (including the opaque signatures) is attached internally by the SDK and
+ // must be serialized verbatim so it can be re-submitted in messages_history.
+ val blocks =
+ List.of(
+ ReasoningBlock.create().content("Step 1: think").signature("sig-1"),
+ ReasoningBlock.create().content("Step 2: conclude").signature("sig-2"));
+
+ val message = new AssistantMessage("The answer is 42.").withReasoningContent(blocks);
+
+ val serialized = (AssistantChatMessage) message.createChatMessage();
+ assertThat(serialized.getReasoningContent()).hasSize(2);
+ assertThat(serialized.getReasoningContent().get(0).getContent()).isEqualTo("Step 1: think");
+ assertThat(serialized.getReasoningContent().get(0).getSignature()).isEqualTo("sig-1");
+ assertThat(serialized.getReasoningContent().get(1).getContent()).isEqualTo("Step 2: conclude");
+ assertThat(serialized.getReasoningContent().get(1).getSignature()).isEqualTo("sig-2");
+ }
+
+ @Test
+ void chatResponseGetReasoningContentReturnsEmptyWhenAbsent() throws Exception {
+ val response = parseSyncResponse("");
+
+ assertThat(response.getReasoningContent()).isEmpty();
+ assertThat(response.getReasoningText()).isEmpty();
+ }
+
+ @Test
+ void chatResponseGetReasoningContentReturnsText() throws Exception {
+ val response =
+ parseSyncResponse(
+ ", \"reasoning_content\": [{\"content\": \"thinking...\", \"signature\": \"sig-a\"}]");
+
+ assertThat(response.getReasoningContent()).containsExactly("thinking...");
+ assertThat(response.getReasoningText()).isEqualTo("thinking...");
+ }
+
+ @Test
+ void getAllMessagesReSubmitsReasoningVerbatim() throws Exception {
+ // The final assistant message reconstructed by getAllMessages() must serialize the reasoning
+ // content (content + opaque signature) exactly as returned, ready for the next request.
+ val response =
+ parseSyncResponse(
+ ", \"reasoning_content\": [{\"content\": \"thinking...\", \"signature\": \"sig-a\"}]");
+
+ val last = response.getLastMessage().createChatMessage();
+ val serialized = (AssistantChatMessage) last;
+ assertThat(serialized.getReasoningContent()).hasSize(1);
+ assertThat(serialized.getReasoningContent().get(0).getContent()).isEqualTo("thinking...");
+ assertThat(serialized.getReasoningContent().get(0).getSignature()).isEqualTo("sig-a");
+ }
+
+ @Test
+ void getAllMessagesReSubmitsHistoryAssistantReasoningVerbatim() throws Exception {
+ val json =
+ new String(
+ java.util.Objects.requireNonNull(
+ getClass()
+ .getClassLoader()
+ .getResourceAsStream("reasoningHistoryResponse.json"))
+ .readAllBytes());
+ val parsed = getOrchestrationObjectMapper().readValue(json, CompletionPostResponse.class);
+ val response = new OrchestrationChatResponse(parsed);
+
+ val messages = response.getAllMessages();
+ // messages[0] user, messages[1] history assistant with reasoning, messages[2] final assistant
+ val serialized = (AssistantChatMessage) messages.get(1).createChatMessage();
+ assertThat(serialized.getReasoningContent()).hasSize(1);
+ assertThat(serialized.getReasoningContent().get(0).getContent()).isEqualTo("past-thought");
+ assertThat(serialized.getReasoningContent().get(0).getSignature()).isEqualTo("past-sig");
+ }
+
+ @Test
+ void reasoningAccumulatorMergesTextByIndex() throws Exception {
+ val chunk1 =
+ parseStreamingDelta(
+ 0,
+ ", \"reasoning_content\": ["
+ + "{\"content\": \"fir\", \"signature\": \"\"},"
+ + "{\"content\": \"sec\", \"signature\": \"\"}]");
+ val chunk2 =
+ parseStreamingDelta(
+ 0,
+ ", \"reasoning_content\": ["
+ + "{\"content\": \"st\", \"signature\": \"\"},"
+ + "{\"content\": \"ond\", \"signature\": \"\"}]");
+
+ val accumulator = new ReasoningAccumulator();
+ accumulator.accept(chunk1);
+ accumulator.accept(chunk2);
+
+ assertThat(accumulator.assemble()).containsExactly("first", "second");
+ }
+
+ @Test
+ void streamingDeltaExposesReasoningText() throws Exception {
+ val delta =
+ parseStreamingDelta(
+ 0, ", \"reasoning_content\": [{\"content\": \"chunk\", \"signature\": \"\"}]");
+
+ assertThat(delta.getDeltaContent()).isEqualTo("hello");
+ assertThat(delta.getDeltaReasoningContent()).containsExactly("chunk");
+ }
+
+ @Test
+ void streamingDeltaReturnsEmptyForNonZeroIndex() throws Exception {
+ val delta =
+ parseStreamingDelta(
+ 1, ", \"reasoning_content\": [{\"content\": \"x\", \"signature\": \"\"}]");
+
+ assertThat(delta.getDeltaContent()).isEmpty();
+ assertThat(delta.getDeltaReasoningContent()).isEmpty();
+
+ // A non-zero-index delta contributes nothing to the accumulator either.
+ val accumulator = new ReasoningAccumulator();
+ accumulator.accept(delta);
+ assertThat(accumulator.assemble()).isEmpty();
+ }
+
+ private static @Nonnull OrchestrationChatResponse parseSyncResponse(
+ final String reasoningJsonFragment) throws Exception {
+ val json = String.format(SYNC_RESPONSE_TEMPLATE, reasoningJsonFragment);
+ val parsed = getOrchestrationObjectMapper().readValue(json, CompletionPostResponse.class);
+ return new OrchestrationChatResponse(parsed);
+ }
+
+ private static @Nonnull OrchestrationChatCompletionDelta parseStreamingDelta(
+ final int index, final String reasoningJsonFragment) throws Exception {
+ val json = String.format(STREAM_CHUNK_TEMPLATE, index, reasoningJsonFragment);
+ val parsed =
+ getOrchestrationObjectMapper().readValue(json, CompletionPostResponseStreaming.class);
+ val delta = new OrchestrationChatCompletionDelta();
+ delta.setRequestId(parsed.getRequestId());
+ delta.setFinalResult(parsed.getFinalResult());
+ return delta;
+ }
+}
diff --git a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java
index 478dfc3f1..376938dde 100644
--- a/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java
+++ b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationUnitTest.java
@@ -81,6 +81,7 @@
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -1734,4 +1735,106 @@ void testFallbackModules() throws IOException {
verify(
postRequestedFor(urlPathEqualTo("/v2/completion")).withRequestBody(equalToJson(request)));
}
+
+ @Test
+ void streamReasoning() throws IOException {
+ final var body =
+ new String(
+ Objects.requireNonNull(
+ getClass().getClassLoader().getResourceAsStream("streamReasoning.txt"))
+ .readAllBytes());
+ stubFor(
+ post(anyUrl())
+ .willReturn(aResponse().withBody(body).withHeader("Content-Type", "application/json")));
+
+ final var reasoningConfig =
+ new OrchestrationModuleConfig()
+ .withLlmConfig(
+ OrchestrationAiModel.CLAUDE_4_5_SONNET.withReasoningEffort(ReasoningEffort.MEDIUM));
+ final var request = OrchestrationClient.toCompletionPostRequest(prompt, reasoningConfig);
+
+ final var answerChunks = new ArrayList();
+ final var accumulator = new ReasoningAccumulator();
+ try (var stream = client.streamChatCompletionDeltas(request)) {
+ stream.forEach(
+ delta -> {
+ if (!delta.getDeltaContent().isEmpty()) {
+ answerChunks.add(delta.getDeltaContent());
+ }
+ accumulator.accept(delta);
+ });
+ }
+ final var reasoning = accumulator.assemble();
+
+ assertThat(answerChunks).containsExactly("42");
+ assertThat(reasoning).containsExactly("Let me think.");
+ }
+
+ @Test
+ void reasoningResponse() {
+ stubFor(
+ post(urlPathEqualTo("/v2/completion"))
+ .willReturn(
+ aResponse()
+ .withBodyFile("reasoningResponse.json")
+ .withHeader("Content-Type", "application/json")));
+
+ final var reasoningConfig =
+ new OrchestrationModuleConfig()
+ .withLlmConfig(
+ OrchestrationAiModel.CLAUDE_4_5_SONNET.withReasoningEffort(ReasoningEffort.MEDIUM));
+ final var reasoningPrompt =
+ new OrchestrationPrompt("Think carefully and explain step by step: Why is the sky blue?");
+
+ final var response = client.chatCompletion(reasoningPrompt, reasoningConfig);
+
+ assertThat(response.getContent()).startsWith("# Why the Sky is Blue");
+ assertThat(response.getReasoningContent()).hasSize(1);
+ assertThat(response.getReasoningContent().get(0))
+ .startsWith("This is a classic question about atmospheric physics.");
+ assertThat(response.getReasoningText()).isEqualTo(response.getReasoningContent().get(0));
+
+ // getAllMessages() must attach the reasoning content (including the opaque signature) to the
+ // final assistant message so it round-trips in messages_history.
+ final var serialized =
+ (com.sap.ai.sdk.orchestration.model.AssistantChatMessage)
+ response.getLastMessage().createChatMessage();
+ assertThat(serialized.getReasoningContent()).hasSize(1);
+ assertThat(serialized.getReasoningContent().get(0).getSignature())
+ .startsWith("EtAECnAIDxABGAIq");
+ }
+
+ @Test
+ void multiTurnReasoningPreservesReasoningContent() {
+ // Turn 1: model returns reasoning content + signature.
+ stubFor(
+ post(urlPathEqualTo("/v2/completion"))
+ .willReturn(
+ aResponse()
+ .withBodyFile("multiTurnReasoningTurn1Response.json")
+ .withHeader("Content-Type", "application/json")));
+
+ final var reasoningConfig =
+ new OrchestrationModuleConfig()
+ .withLlmConfig(
+ OrchestrationAiModel.CLAUDE_4_5_SONNET.withReasoningEffort(ReasoningEffort.MEDIUM));
+ final var firstResponse =
+ client.chatCompletion(new OrchestrationPrompt("What is 6 times 7?"), reasoningConfig);
+
+ // Turn 1 assertions: reasoning text parsed correctly.
+ assertThat(firstResponse.getContent()).isEqualTo("6 times 7 is 42.");
+ assertThat(firstResponse.getReasoningContent()).hasSize(1);
+
+ // Turn 2: send a follow-up carrying the whole history (which includes turn 1's reasoning).
+ final var followUp =
+ new OrchestrationPrompt("Are you sure?").messageHistory(firstResponse.getAllMessages());
+ client.chatCompletion(followUp, reasoningConfig);
+
+ // The outgoing request for turn 2 must contain the reasoning_content from turn 1 (content +
+ // signature) in the messages_history array. This is the multi-turn round-trip contract.
+ final String expectedTurn2Request = fileLoaderStr.apply("multiTurnReasoningTurn2Request.json");
+ verify(
+ postRequestedFor(urlPathEqualTo("/v2/completion"))
+ .withRequestBody(equalToJson(expectedTurn2Request, true, true)));
+ }
}
diff --git a/orchestration/src/test/resources/__files/multiTurnReasoningTurn1Response.json b/orchestration/src/test/resources/__files/multiTurnReasoningTurn1Response.json
new file mode 100644
index 000000000..0b82c1462
--- /dev/null
+++ b/orchestration/src/test/resources/__files/multiTurnReasoningTurn1Response.json
@@ -0,0 +1,54 @@
+{
+ "request_id": "46f4b2ea-8fd1-9611-84ee-9b072ebe2aae",
+ "intermediate_results": {
+ "templating": [
+ {"content": "What is 6 times 7?", "role": "user"}
+ ],
+ "llm": {
+ "id": "msg_bdrk_011mH16vLzW8J9AF3FP5JaCs",
+ "object": "chat.completion",
+ "created": 1783556133,
+ "model": "claude-sonnet-4-5-20250929",
+ "choices": [
+ {
+ "index": 0,
+ "message": {
+ "role": "assistant",
+ "content": "6 times 7 is 42.",
+ "reasoning_content": [
+ {
+ "content": "This is a simple multiplication question. 6 times 7 equals 42.",
+ "signature": "EpACCnAIDxABGAIqQJg6O4DbYCclwzR0D0KGKTpCDTQ4PD6YxelN1aci6UiUmvX2Xg0ZNzAtdUMekPynzA57/5i0wjkp+vdcrpDn4OUyGmNsYXVkZS1zb25uZXQtNC01LTIwMjUwOTI5OABCCHRoaW5raW5nEgxq8jJz+WuqAkZKabEaDH1IeHlDMiXUwN8XDiIwcjwy62HqlyTXMhRmrEkybRtq4wD7GOaQ4xrpmJuy9T5Z1xAjmNtl3y8dQmFuQDPoKk4n2gAfLmXS+IQyN4bmc0BaFT7RkzNUdkak4pTtKeWwLo55cHdBBmGQsFtpEV7G28AEsqTyy9Qak6urih233RHKDLHzh9banjoPgHTzLX0YAQ=="
+ }
+ ]
+ },
+ "finish_reason": "stop"
+ }
+ ],
+ "usage": {"completion_tokens": 39, "prompt_tokens": 45, "total_tokens": 84}
+ }
+ },
+ "final_result": {
+ "id": "msg_bdrk_011mH16vLzW8J9AF3FP5JaCs",
+ "object": "chat.completion",
+ "created": 1783556133,
+ "model": "claude-sonnet-4-5-20250929",
+ "choices": [
+ {
+ "index": 0,
+ "message": {
+ "role": "assistant",
+ "content": "6 times 7 is 42.",
+ "reasoning_content": [
+ {
+ "content": "This is a simple multiplication question. 6 times 7 equals 42.",
+ "signature": "EpACCnAIDxABGAIqQJg6O4DbYCclwzR0D0KGKTpCDTQ4PD6YxelN1aci6UiUmvX2Xg0ZNzAtdUMekPynzA57/5i0wjkp+vdcrpDn4OUyGmNsYXVkZS1zb25uZXQtNC01LTIwMjUwOTI5OABCCHRoaW5raW5nEgxq8jJz+WuqAkZKabEaDH1IeHlDMiXUwN8XDiIwcjwy62HqlyTXMhRmrEkybRtq4wD7GOaQ4xrpmJuy9T5Z1xAjmNtl3y8dQmFuQDPoKk4n2gAfLmXS+IQyN4bmc0BaFT7RkzNUdkak4pTtKeWwLo55cHdBBmGQsFtpEV7G28AEsqTyy9Qak6urih233RHKDLHzh9banjoPgHTzLX0YAQ=="
+ }
+ ]
+ },
+ "finish_reason": "stop"
+ }
+ ],
+ "usage": {"completion_tokens": 39, "prompt_tokens": 45, "total_tokens": 84}
+ }
+}
diff --git a/orchestration/src/test/resources/__files/reasoningResponse.json b/orchestration/src/test/resources/__files/reasoningResponse.json
new file mode 100644
index 000000000..36563f99f
--- /dev/null
+++ b/orchestration/src/test/resources/__files/reasoningResponse.json
@@ -0,0 +1,65 @@
+{
+ "request_id": "47390784-5290-9a01-8054-0aaafc0080f7",
+ "intermediate_results": {
+ "templating": [
+ {
+ "content": "Think carefully and explain step by step: Why is the sky blue?",
+ "role": "user"
+ }
+ ],
+ "llm": {
+ "id": "msg_bdrk_013jkzoRw22UxBrRiXxXTRGc",
+ "object": "chat.completion",
+ "created": 1783555720,
+ "model": "claude-sonnet-4-5-20250929",
+ "choices": [
+ {
+ "index": 0,
+ "message": {
+ "role": "assistant",
+ "content": "# Why the Sky is Blue: A Step-by-Step Explanation\n\n## Step 1: Sunlight is Made of Many Colors\n- Sunlight appears white, but it's actually composed of all colors of the visible spectrum (red, orange, yellow, green, blue, indigo, violet)\n- These colors have different wavelengths - red has the longest, violet has the shortest\n\n## Step 2: Sunlight Enters Earth's Atmosphere\n- When sunlight reaches Earth, it must pass through our atmosphere\n- The atmosphere contains tiny molecules of gas (mostly nitrogen and oxygen)\n\n## Step 3: Rayleigh Scattering Occurs\n- As sunlight collides with these tiny atmospheric molecules, it scatters in all directions\n- **Rayleigh scattering** is the key: shorter wavelengths scatter much more than longer wavelengths\n- Blue and violet light (short wavelengths) scatter approximately 10 times more than red light (long wavelength)\n\n## Step 4: Why Blue and Not Violet?\nSince violet scatters even more than blue, you might wonder why the sky isn't violet:\n- Our eyes are less sensitive to violet light\n- The sun emits less violet light than blue\n- Some violet light is absorbed by the upper atmosphere\n\n## Step 5: The Result\n- Blue light bounces around the sky in all directions\n- No matter where you look, scattered blue light reaches your eyes\n- This creates the blue \"dome\" effect we see\n\n**At sunset/sunrise:** The sun's light travels through more atmosphere, scattering away most blue light, leaving the reds and oranges we see.",
+ "reasoning_content": [
+ {
+ "content": "This is a classic question about atmospheric physics. Let me explain this step by step, carefully and accurately.\n\nThe sky appears blue due to a phenomenon called Rayleigh scattering. Let me break this down:\n\n1. Sunlight composition\n2. Interaction with atmosphere\n3. Rayleigh scattering\n4. Why we see blue specifically\n5. Related phenomena\n\nLet me work through each step carefully.",
+ "signature": "EtAECnAIDxABGAIqQOKoKaJr9CoLA0RWx4FiAuN/ix+c6XzRsR4j1qqbTCyFvU4vKCMhAa64pWN/KODOISL4ivPR/SbqzMMRjqlf/LQyGmNsYXVkZS1zb25uZXQtNC01LTIwMjUwOTI5OABCCHRoaW5raW5nEgzrJj94teHdUB0YYTQaDPWHrEfYOaLA4uXQmSIwkSENGMxXoKkeQlxVFmoKondwHCWbQpyZwEJ6R6TBVwKSeVHaHO3JinB/JhzNOEWyKo0DhSU159efWblPZapxyvzxlaceJRa2e87KdRTfoUAuxhm4OcHjrzEl5TUPvNHDKwm/PYU53Tu3MEa8ByTn8In5UTRTfam+LP8j/9xOWwGSh6drg2Xnvf5MgxMQx00sOwTNUGUYfTBNuPb6WKRfafp2DhmRYYwnE/PyLZnZT0HQihOVk6xPKY4cKALryz4e3LxcMM2cEDhqisHqbv8FjPNDUU7xw0M1K9s+oig/8iNpHlBuKmvXmJkLVs+To6VZZNs2OG3KpXREZtiS3I9zJFJC07fglAK0cknt21fD5frvDG2C/wXo0GETMrIl7RhhI9bU8HTY14zjQW47FLwFRhMz0Ybk5Cwe4V2jVVbdAha/dwMRKbqccdNIgdbTKgfHPQUsmTjCPLZq8U8HuNSuEJI8njPE9mrZO4ufvgDI4b57ccDwXFHGLHFM4PCyIhQp8kQNhTj20yrU0aq9PEcrRpGx6i2KnWo/67pl/KffNhYOrAoJe7tlrtASCJR99cpOsDzblG9UT+OUwUMbVQgfghgB"
+ }
+ ]
+ },
+ "finish_reason": "stop"
+ }
+ ],
+ "usage": {
+ "completion_tokens": 466,
+ "prompt_tokens": 50,
+ "total_tokens": 516
+ }
+ }
+ },
+ "final_result": {
+ "id": "msg_bdrk_013jkzoRw22UxBrRiXxXTRGc",
+ "object": "chat.completion",
+ "created": 1783555720,
+ "model": "claude-sonnet-4-5-20250929",
+ "choices": [
+ {
+ "index": 0,
+ "message": {
+ "role": "assistant",
+ "content": "# Why the Sky is Blue: A Step-by-Step Explanation\n\n## Step 1: Sunlight is Made of Many Colors\n- Sunlight appears white, but it's actually composed of all colors of the visible spectrum (red, orange, yellow, green, blue, indigo, violet)\n- These colors have different wavelengths - red has the longest, violet has the shortest\n\n## Step 2: Sunlight Enters Earth's Atmosphere\n- When sunlight reaches Earth, it must pass through our atmosphere\n- The atmosphere contains tiny molecules of gas (mostly nitrogen and oxygen)\n\n## Step 3: Rayleigh Scattering Occurs\n- As sunlight collides with these tiny atmospheric molecules, it scatters in all directions\n- **Rayleigh scattering** is the key: shorter wavelengths scatter much more than longer wavelengths\n- Blue and violet light (short wavelengths) scatter approximately 10 times more than red light (long wavelength)\n\n## Step 4: Why Blue and Not Violet?\nSince violet scatters even more than blue, you might wonder why the sky isn't violet:\n- Our eyes are less sensitive to violet light\n- The sun emits less violet light than blue\n- Some violet light is absorbed by the upper atmosphere\n\n## Step 5: The Result\n- Blue light bounces around the sky in all directions\n- No matter where you look, scattered blue light reaches your eyes\n- This creates the blue \"dome\" effect we see\n\n**At sunset/sunrise:** The sun's light travels through more atmosphere, scattering away most blue light, leaving the reds and oranges we see.",
+ "reasoning_content": [
+ {
+ "content": "This is a classic question about atmospheric physics. Let me explain this step by step, carefully and accurately.\n\nThe sky appears blue due to a phenomenon called Rayleigh scattering. Let me break this down:\n\n1. Sunlight composition\n2. Interaction with atmosphere\n3. Rayleigh scattering\n4. Why we see blue specifically\n5. Related phenomena\n\nLet me work through each step carefully.",
+ "signature": "EtAECnAIDxABGAIqQOKoKaJr9CoLA0RWx4FiAuN/ix+c6XzRsR4j1qqbTCyFvU4vKCMhAa64pWN/KODOISL4ivPR/SbqzMMRjqlf/LQyGmNsYXVkZS1zb25uZXQtNC01LTIwMjUwOTI5OABCCHRoaW5raW5nEgzrJj94teHdUB0YYTQaDPWHrEfYOaLA4uXQmSIwkSENGMxXoKkeQlxVFmoKondwHCWbQpyZwEJ6R6TBVwKSeVHaHO3JinB/JhzNOEWyKo0DhSU159efWblPZapxyvzxlaceJRa2e87KdRTfoUAuxhm4OcHjrzEl5TUPvNHDKwm/PYU53Tu3MEa8ByTn8In5UTRTfam+LP8j/9xOWwGSh6drg2Xnvf5MgxMQx00sOwTNUGUYfTBNuPb6WKRfafp2DhmRYYwnE/PyLZnZT0HQihOVk6xPKY4cKALryz4e3LxcMM2cEDhqisHqbv8FjPNDUU7xw0M1K9s+oig/8iNpHlBuKmvXmJkLVs+To6VZZNs2OG3KpXREZtiS3I9zJFJC07fglAK0cknt21fD5frvDG2C/wXo0GETMrIl7RhhI9bU8HTY14zjQW47FLwFRhMz0Ybk5Cwe4V2jVVbdAha/dwMRKbqccdNIgdbTKgfHPQUsmTjCPLZq8U8HuNSuEJI8njPE9mrZO4ufvgDI4b57ccDwXFHGLHFM4PCyIhQp8kQNhTj20yrU0aq9PEcrRpGx6i2KnWo/67pl/KffNhYOrAoJe7tlrtASCJR99cpOsDzblG9UT+OUwUMbVQgfghgB"
+ }
+ ]
+ },
+ "finish_reason": "stop"
+ }
+ ],
+ "usage": {
+ "completion_tokens": 466,
+ "prompt_tokens": 50,
+ "total_tokens": 516
+ }
+ }
+}
diff --git a/orchestration/src/test/resources/multiTurnReasoningTurn1Request.json b/orchestration/src/test/resources/multiTurnReasoningTurn1Request.json
new file mode 100644
index 000000000..7d9f0822e
--- /dev/null
+++ b/orchestration/src/test/resources/multiTurnReasoningTurn1Request.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "modules": {
+ "prompt_templating": {
+ "prompt": {
+ "template": [
+ {
+ "content": "What is 6 times 7?",
+ "role": "user"
+ }
+ ],
+ "defaults": {},
+ "tools": []
+ },
+ "model": {
+ "name": "anthropic--claude-4.5-sonnet",
+ "version": "latest",
+ "params": {
+ "reasoning_effort": "medium"
+ },
+ "timeout": 600,
+ "max_retries": 2
+ }
+ }
+ }
+ },
+ "placeholder_values": {},
+ "messages_history": []
+}
diff --git a/orchestration/src/test/resources/multiTurnReasoningTurn2Request.json b/orchestration/src/test/resources/multiTurnReasoningTurn2Request.json
new file mode 100644
index 000000000..4d6da5fd8
--- /dev/null
+++ b/orchestration/src/test/resources/multiTurnReasoningTurn2Request.json
@@ -0,0 +1,38 @@
+{
+ "config": {
+ "modules": {
+ "prompt_templating": {
+ "prompt": {
+ "template": [
+ {
+ "content": "Are you sure?",
+ "role": "user"
+ }
+ ]
+ },
+ "model": {
+ "name": "anthropic--claude-4.5-sonnet",
+ "params": {
+ "reasoning_effort": "medium"
+ }
+ }
+ }
+ }
+ },
+ "messages_history": [
+ {
+ "content": "What is 6 times 7?",
+ "role": "user"
+ },
+ {
+ "role": "assistant",
+ "content": "6 times 7 is 42.",
+ "reasoning_content": [
+ {
+ "content": "This is a simple multiplication question. 6 times 7 equals 42.",
+ "signature": "EpACCnAIDxABGAIqQJg6O4DbYCclwzR0D0KGKTpCDTQ4PD6YxelN1aci6UiUmvX2Xg0ZNzAtdUMekPynzA57/5i0wjkp+vdcrpDn4OUyGmNsYXVkZS1zb25uZXQtNC01LTIwMjUwOTI5OABCCHRoaW5raW5nEgxq8jJz+WuqAkZKabEaDH1IeHlDMiXUwN8XDiIwcjwy62HqlyTXMhRmrEkybRtq4wD7GOaQ4xrpmJuy9T5Z1xAjmNtl3y8dQmFuQDPoKk4n2gAfLmXS+IQyN4bmc0BaFT7RkzNUdkak4pTtKeWwLo55cHdBBmGQsFtpEV7G28AEsqTyy9Qak6urih233RHKDLHzh9banjoPgHTzLX0YAQ=="
+ }
+ ]
+ }
+ ]
+}
diff --git a/orchestration/src/test/resources/reasoningHistoryResponse.json b/orchestration/src/test/resources/reasoningHistoryResponse.json
new file mode 100644
index 000000000..ef8eddec4
--- /dev/null
+++ b/orchestration/src/test/resources/reasoningHistoryResponse.json
@@ -0,0 +1,19 @@
+{
+ "intermediate_results": {
+ "templating": [
+ {"role": "user", "content": "hi"},
+ {
+ "role": "assistant",
+ "content": "hello",
+ "reasoning_content": [{"content": "past-thought", "signature": "past-sig"}]
+ }
+ ]
+ },
+ "final_result": {
+ "choices": [
+ {
+ "message": {"role": "assistant", "content": "final answer"}
+ }
+ ]
+ }
+}
diff --git a/orchestration/src/test/resources/reasoningRequest.json b/orchestration/src/test/resources/reasoningRequest.json
new file mode 100644
index 000000000..399f20838
--- /dev/null
+++ b/orchestration/src/test/resources/reasoningRequest.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "modules": {
+ "prompt_templating": {
+ "prompt": {
+ "template": [
+ {
+ "content": "Think carefully and explain step by step: Why is the sky blue?",
+ "role": "user"
+ }
+ ],
+ "defaults": {},
+ "tools": []
+ },
+ "model": {
+ "name": "anthropic--claude-4.5-sonnet",
+ "version": "latest",
+ "params": {
+ "reasoning_effort": "medium"
+ },
+ "timeout": 600,
+ "max_retries": 2
+ }
+ }
+ }
+ },
+ "placeholder_values": {},
+ "messages_history": []
+}
diff --git a/orchestration/src/test/resources/streamReasoning.txt b/orchestration/src/test/resources/streamReasoning.txt
new file mode 100644
index 000000000..cb9c4a108
--- /dev/null
+++ b/orchestration/src/test/resources/streamReasoning.txt
@@ -0,0 +1,6 @@
+data: {"request_id": "req", "intermediate_results": {"templating": [{"role": "user", "content": "hi"}]}, "final_result": {"id": "", "object": "", "created": 0, "model": "", "choices": [{"index": 0, "delta": {"role": "", "content": ""}, "finish_reason": ""}]}}
+data: {"request_id": "req", "final_result": {"id": "id", "object": "chat.completion.chunk", "created": 0, "model": "m", "choices": [{"index": 0, "delta": {"role": "assistant", "content": "", "reasoning_content": [{"content": "Let me ", "signature": ""}]}, "finish_reason": ""}]}}
+data: {"request_id": "req", "final_result": {"id": "id", "object": "chat.completion.chunk", "created": 0, "model": "m", "choices": [{"index": 0, "delta": {"role": "assistant", "content": "", "reasoning_content": [{"content": "think.", "signature": ""}]}, "finish_reason": ""}]}}
+data: {"request_id": "req", "final_result": {"id": "id", "object": "chat.completion.chunk", "created": 0, "model": "m", "choices": [{"index": 0, "delta": {"role": "assistant", "content": "", "reasoning_content": [{"content": "", "signature": "sig-final"}]}, "finish_reason": ""}]}}
+data: {"request_id": "req", "final_result": {"id": "id", "object": "chat.completion.chunk", "created": 0, "model": "m", "choices": [{"index": 0, "delta": {"role": "assistant", "content": "42"}, "finish_reason": "stop"}]}}
+data: [DONE]
diff --git a/orchestration/src/test/resources/streamReasoningRequest.json b/orchestration/src/test/resources/streamReasoningRequest.json
new file mode 100644
index 000000000..a84115526
--- /dev/null
+++ b/orchestration/src/test/resources/streamReasoningRequest.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "modules": {
+ "prompt_templating": {
+ "prompt": {
+ "template": [
+ {
+ "content": "Think carefully and explain step by step: Why is the sky blue?",
+ "role": "user"
+ }
+ ],
+ "defaults": {},
+ "tools": []
+ },
+ "model": {
+ "name": "anthropic--claude-4.5-sonnet",
+ "version": "latest",
+ "params": {
+ "reasoning_effort": "medium"
+ },
+ "timeout": 600,
+ "max_retries": 2
+ }
+ }
+ },
+ "stream": {"enabled": true, "chunk_size": 100}
+ },
+ "placeholder_values": {},
+ "messages_history": []
+}
diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java
index bff2c8e1d..441294929 100644
--- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java
+++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/controllers/OrchestrationController.java
@@ -79,6 +79,46 @@ ResponseEntity streamChatCompletion() {
return ResponseEntity.ok().contentType(MediaType.TEXT_EVENT_STREAM).body(emitter);
}
+ @GetMapping("/reasoning")
+ Object reasoning(
+ @RequestParam(value = "topic", defaultValue = "Why is the sky blue?") final String topic) {
+ return service.reasoning(topic);
+ }
+
+ @GetMapping("/multiTurnReasoning")
+ Object multiTurnReasoning(
+ @RequestParam(value = "first", defaultValue = "What is 6 times 7?") final String first,
+ @RequestParam(value = "followUp", defaultValue = "Are you sure?") final String followUp) {
+ return service.multiTurnReasoning(first, followUp);
+ }
+
+ @GetMapping("/streamReasoning")
+ ResponseEntity streamReasoning(
+ @RequestParam(value = "topic", defaultValue = "Why is the sky blue?") final String topic) {
+ final var emitter = new ResponseBodyEmitter();
+ final Runnable consumeStream =
+ () -> {
+ try {
+ final var accumulated =
+ service.streamReasoning(
+ topic,
+ answer -> send(emitter, "[answer] " + answer),
+ reasoning -> send(emitter, "[reasoning] " + reasoning));
+ // Emit the fully accumulated reasoning text at the end of the stream.
+ for (int i = 0; i < accumulated.size(); i++) {
+ send(emitter, "[accumulated block " + i + "] " + accumulated.get(i));
+ }
+ } finally {
+ emitter.complete();
+ }
+ };
+
+ ThreadContextExecutors.getExecutor().execute(consumeStream);
+
+ // TEXT_EVENT_STREAM allows the browser to display the content as it is streamed
+ return ResponseEntity.ok().contentType(MediaType.TEXT_EVENT_STREAM).body(emitter);
+ }
+
@GetMapping("/template")
Object template(@Nullable @RequestParam(value = "format", required = false) final String format) {
final var response = service.template("German");
diff --git a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OrchestrationService.java b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OrchestrationService.java
index f653681cc..38769a7fd 100644
--- a/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OrchestrationService.java
+++ b/sample-code/spring-app/src/main/java/com/sap/ai/sdk/app/services/OrchestrationService.java
@@ -1,5 +1,6 @@
package com.sap.ai.sdk.app.services;
+import static com.sap.ai.sdk.orchestration.OrchestrationAiModel.CLAUDE_4_5_SONNET;
import static com.sap.ai.sdk.orchestration.OrchestrationAiModel.GEMINI_2_5_FLASH;
import static com.sap.ai.sdk.orchestration.OrchestrationAiModel.GPT_41_NANO;
import static com.sap.ai.sdk.orchestration.OrchestrationAiModel.GPT_5_MINI;
@@ -27,6 +28,8 @@
import com.sap.ai.sdk.orchestration.OrchestrationEmbeddingResponse;
import com.sap.ai.sdk.orchestration.OrchestrationModuleConfig;
import com.sap.ai.sdk.orchestration.OrchestrationPrompt;
+import com.sap.ai.sdk.orchestration.ReasoningAccumulator;
+import com.sap.ai.sdk.orchestration.ReasoningEffort;
import com.sap.ai.sdk.orchestration.ResponseJsonSchema;
import com.sap.ai.sdk.orchestration.SystemMessage;
import com.sap.ai.sdk.orchestration.TemplateConfig;
@@ -57,6 +60,7 @@
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
+import java.util.function.Consumer;
import java.util.stream.Stream;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
@@ -179,6 +183,95 @@ public Stream streamChatCompletion(@Nonnull final String topic) {
return client.streamChatCompletion(prompt, config);
}
+ /**
+ * Chat request to a reasoning-capable model through the Orchestration service, returning both the
+ * final answer and the reasoning (thinking) trace.
+ *
+ * @param topic the topic to ask about.
+ * @return a {@link ReasoningResult} with the answer and the reasoning blocks.
+ */
+ @Nonnull
+ public ReasoningResult reasoning(@Nonnull final String topic) {
+ val reasoningConfig =
+ config.withLlmConfig(CLAUDE_4_5_SONNET.withReasoningEffort(ReasoningEffort.MEDIUM));
+ val prompt = new OrchestrationPrompt("Think carefully and explain step by step: " + topic);
+ val response = client.chatCompletion(prompt, reasoningConfig);
+ return new ReasoningResult(response.getContent(), response.getReasoningContent());
+ }
+
+ /**
+ * The result of a reasoning-model chat request: the final answer plus the model's reasoning
+ * (thinking) text.
+ *
+ * @param answer the final assistant reply.
+ * @param reasoning the reasoning text produced by the model (may be empty).
+ */
+ public record ReasoningResult(@Nonnull String answer, @Nonnull List reasoning) {}
+
+ /**
+ * Streaming chat request to a reasoning-capable model. Answer and reasoning chunks are pushed
+ * live to the given consumers; the accumulated reasoning text is returned.
+ *
+ * @param topic the topic to ask about.
+ * @param onAnswerChunk consumer invoked with each answer text chunk.
+ * @param onReasoningChunk consumer invoked with each reasoning text chunk.
+ * @return the assembled reasoning text as returned by the API.
+ */
+ @Nonnull
+ public List streamReasoning(
+ @Nonnull final String topic,
+ @Nonnull final Consumer onAnswerChunk,
+ @Nonnull final Consumer onReasoningChunk) {
+ val reasoningConfig =
+ config.withLlmConfig(CLAUDE_4_5_SONNET.withReasoningEffort(ReasoningEffort.MEDIUM));
+ val prompt = new OrchestrationPrompt("Think carefully and explain step by step: " + topic);
+ val request = OrchestrationClient.toCompletionPostRequest(prompt, reasoningConfig);
+ val accumulator = new ReasoningAccumulator();
+ try (val stream = client.streamChatCompletionDeltas(request)) {
+ stream.forEach(
+ delta -> {
+ val content = delta.getDeltaContent();
+ if (!content.isEmpty()) {
+ onAnswerChunk.accept(content);
+ }
+ for (val reasoningText : delta.getDeltaReasoningContent()) {
+ if (!reasoningText.isEmpty()) {
+ onReasoningChunk.accept(reasoningText);
+ }
+ }
+ accumulator.accept(delta);
+ });
+ }
+ return accumulator.assemble();
+ }
+
+ /**
+ * Two-turn conversation against a reasoning-capable model.
+ *
+ * @param firstQuestion the initial question.
+ * @param followUp the follow-up question.
+ * @return the second turn's assistant reply.
+ */
+ @Nonnull
+ public ReasoningResult multiTurnReasoning(
+ @Nonnull final String firstQuestion, @Nonnull final String followUp) {
+ val reasoningConfig =
+ config.withLlmConfig(CLAUDE_4_5_SONNET.withReasoningEffort(ReasoningEffort.MEDIUM));
+
+ // Turn 1
+ val firstPrompt = new OrchestrationPrompt(firstQuestion);
+ val firstResponse = client.chatCompletion(firstPrompt, reasoningConfig);
+
+ // Turn 2 feeds the whole history back. getAllMessages() already carries the reasoning content
+ // on the final assistant message.
+ val followUpPrompt =
+ new OrchestrationPrompt(followUp).messageHistory(firstResponse.getAllMessages());
+ val followUpResponse = client.chatCompletion(followUpPrompt, reasoningConfig);
+
+ return new ReasoningResult(
+ followUpResponse.getContent(), followUpResponse.getReasoningContent());
+ }
+
/**
* Chat request to OpenAI through the Orchestration service with a template.
*
diff --git a/sample-code/spring-app/src/main/resources/static/index.html b/sample-code/spring-app/src/main/resources/static/index.html
index be8accffe..33073f349 100644
--- a/sample-code/spring-app/src/main/resources/static/index.html
+++ b/sample-code/spring-app/src/main/resources/static/index.html
@@ -266,6 +266,42 @@ Orchestration
+
+
+
+
+
+
+
+
+