From a1e91607812ed4e0ba66da68b7ebf95c55f925fd Mon Sep 17 00:00:00 2001 From: Nourhan Shata Date: Thu, 9 Jul 2026 10:10:32 +0200 Subject: [PATCH 1/5] full implementation --- orchestration/pom.xml | 8 +- .../sdk/orchestration/AssistantMessage.java | 33 +++- .../orchestration/OrchestrationAiModel.java | 16 ++ .../OrchestrationChatCompletionDelta.java | 19 +++ .../OrchestrationChatResponse.java | 46 +++++- .../orchestration/OrchestrationClient.java | 49 ++++++ .../orchestration/ReasoningAccumulator.java | 52 ++++++ .../ai/sdk/orchestration/ReasoningEffort.java | 41 +++++ .../OrchestrationReasoningContentTest.java | 156 ++++++++++++++++++ .../orchestration/OrchestrationUnitTest.java | 98 +++++++++++ .../multiTurnReasoningTurn1Response.json | 54 ++++++ .../resources/__files/reasoningResponse.json | 65 ++++++++ .../multiTurnReasoningTurn1Request.json | 29 ++++ .../multiTurnReasoningTurn2Request.json | 38 +++++ .../resources/reasoningHistoryResponse.json | 19 +++ .../src/test/resources/reasoningRequest.json | 29 ++++ .../src/test/resources/streamReasoning.txt | 6 + .../resources/streamReasoningRequest.json | 30 ++++ .../controllers/OrchestrationController.java | 49 ++++++ .../app/services/OrchestrationService.java | 77 +++++++++ .../src/main/resources/static/index.html | 36 ++++ .../app/controllers/OrchestrationTest.java | 44 +++++ 22 files changed, 984 insertions(+), 10 deletions(-) create mode 100644 orchestration/src/main/java/com/sap/ai/sdk/orchestration/ReasoningAccumulator.java create mode 100644 orchestration/src/main/java/com/sap/ai/sdk/orchestration/ReasoningEffort.java create mode 100644 orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationReasoningContentTest.java create mode 100644 orchestration/src/test/resources/__files/multiTurnReasoningTurn1Response.json create mode 100644 orchestration/src/test/resources/__files/reasoningResponse.json create mode 100644 orchestration/src/test/resources/multiTurnReasoningTurn1Request.json create mode 100644 orchestration/src/test/resources/multiTurnReasoningTurn2Request.json create mode 100644 orchestration/src/test/resources/reasoningHistoryResponse.json create mode 100644 orchestration/src/test/resources/reasoningRequest.json create mode 100644 orchestration/src/test/resources/streamReasoning.txt create mode 100644 orchestration/src/test/resources/streamReasoningRequest.json diff --git a/orchestration/pom.xml b/orchestration/pom.xml index df04c2286..461db0a78 100644 --- a/orchestration/pom.xml +++ b/orchestration/pom.xml @@ -36,10 +36,10 @@ ${project.basedir}/../ - 82% - 94% - 93% - 75% + 83% + 95% + 94% + 77% 95% 100% diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/AssistantMessage.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/AssistantMessage.java index 26c6f472c..e23c75916 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/AssistantMessage.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/AssistantMessage.java @@ -6,6 +6,7 @@ import com.sap.ai.sdk.orchestration.model.ChatMessage; import com.sap.ai.sdk.orchestration.model.ChatMessageContent; import com.sap.ai.sdk.orchestration.model.MessageToolCall; +import com.sap.ai.sdk.orchestration.model.ReasoningBlock; import com.sap.ai.sdk.orchestration.model.TextContent; import java.util.ArrayList; import java.util.List; @@ -31,6 +32,9 @@ public class AssistantMessage implements Message { /** Tool call if there is any. */ @Nullable List toolCalls; + /** Reasoning (thinking) content produced by the model, if any. */ + @Nullable List reasoningContent; + /** * Creates a new assistant message with the given single message. * @@ -39,6 +43,7 @@ public class AssistantMessage implements Message { public AssistantMessage(@Nonnull final String singleMessage) { content = new MessageContent(List.of(new TextItem(singleMessage))); toolCalls = null; + reasoningContent = null; } /** @@ -49,6 +54,7 @@ public AssistantMessage(@Nonnull final String singleMessage) { AssistantMessage(@Nonnull final MessageContent content) { this.content = content; toolCalls = null; + reasoningContent = null; } /** @@ -61,12 +67,16 @@ public AssistantMessage(@Nonnull final String singleMessage) { public AssistantMessage(@Nonnull final List toolCalls) { content = new MessageContent(List.of()); this.toolCalls = toolCalls; + reasoningContent = null; } private AssistantMessage( - @Nonnull final MessageContent content, @Nullable final List toolCalls) { + @Nonnull final MessageContent content, + @Nullable final List toolCalls, + @Nullable final List reasoningContent) { this.content = content; this.toolCalls = toolCalls; + this.reasoningContent = reasoningContent; } /** @@ -81,7 +91,22 @@ private AssistantMessage( public AssistantMessage withToolCalls(@Nonnull final List toolCalls) { val newToolcalls = new ArrayList<>(this.toolCalls != null ? this.toolCalls : List.of()); newToolcalls.addAll(toolCalls); - return new AssistantMessage(this.content, newToolcalls); + return new AssistantMessage(this.content, newToolcalls, this.reasoningContent); + } + + /** + * Returns a new AssistantMessage instance carrying the given reasoning content. + * + * @param reasoningContent the reasoning blocks from the previous assistant turn. + * @return a new AssistantMessage instance with the reasoning content set. + * @see SAP AI Core: + * Orchestration - Reasoning + */ + @SuppressWarnings("PMD.PublicApiExposesModelType") + @Nonnull + public AssistantMessage withReasoningContent( + @Nonnull final List reasoningContent) { + return new AssistantMessage(this.content, this.toolCalls, List.copyOf(reasoningContent)); } @Nonnull @@ -93,6 +118,10 @@ public ChatMessage createChatMessage() { assistantChatMessage.setToolCalls(toolCalls); } + if (reasoningContent != null) { + assistantChatMessage.setReasoningContent(reasoningContent); + } + ChatMessageContent text; if (content.items().size() == 1 && content.items().get(0) instanceof TextItem textItem) { text = ChatMessageContent.create(textItem.text()); diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationAiModel.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationAiModel.java index 1345deaba..0f996e5ea 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationAiModel.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationAiModel.java @@ -518,6 +518,19 @@ public OrchestrationAiModel withParam( return withParam(param.getName(), value); } + /** + * Set the {@code reasoning_effort} parameter on this model. + * + * @param effort the reasoning effort level. + * @return a new model with the {@code reasoning_effort} parameter set. + * @see SAP AI Core: + * Orchestration - Reasoning + */ + @Nonnull + public OrchestrationAiModel withReasoningEffort(@Nonnull final ReasoningEffort effort) { + return withParam(Parameter.REASONING_EFFORT, effort.getValue()); + } + /** * Parameter key for a model. * @@ -543,6 +556,9 @@ public interface Parameter { /** The number of chat completion choices to generate for each input message. */ Parameter N = () -> "n"; + /** The reasoning effort for reasoning-capable models. */ + Parameter REASONING_EFFORT = () -> "reasoning_effort"; + /** * The name of the parameter. * diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatCompletionDelta.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatCompletionDelta.java index c9b2c882d..6f06dfa69 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatCompletionDelta.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatCompletionDelta.java @@ -2,6 +2,8 @@ import com.sap.ai.sdk.core.common.StreamedDelta; 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 javax.annotation.Nullable; import lombok.val; @@ -27,6 +29,23 @@ public String getDeltaContent() { return ""; } + /** + * Get the reasoning (thinking) blocks carried by this chunk. + * + * @return the reasoning blocks in this chunk; never {@code null}, may be empty. + * @see SAP AI Core: + * Orchestration - Reasoning + */ + @SuppressWarnings("PMD.PublicApiExposesModelType") + @Nonnull + public List getDeltaReasoningContent() { + val choices = getFinalResult().getChoices(); + if (!choices.isEmpty() && choices.get(0).getIndex() == 0) { + return choices.get(0).getDelta().getReasoningContent(); + } + return List.of(); + } + @Nullable @Override public String getFinishReason() { diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java index 629bf506a..5faf5ca1e 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationChatResponse.java @@ -1,5 +1,6 @@ package com.sap.ai.sdk.orchestration; +import static java.util.stream.Collectors.joining; import static lombok.AccessLevel.PACKAGE; import com.fasterxml.jackson.core.JsonProcessingException; @@ -10,6 +11,7 @@ import com.sap.ai.sdk.orchestration.model.ChatMessageContent; import com.sap.ai.sdk.orchestration.model.CompletionPostResponse; import com.sap.ai.sdk.orchestration.model.LLMChoice; +import com.sap.ai.sdk.orchestration.model.ReasoningBlock; import com.sap.ai.sdk.orchestration.model.SystemChatMessage; import com.sap.ai.sdk.orchestration.model.TokenUsage; import com.sap.ai.sdk.orchestration.model.ToolChatMessage; @@ -67,6 +69,31 @@ public TokenUsage getTokenUsage() { return originalResponse.getFinalResult().getUsage(); } + /** + * Get the reasoning (thinking) content produced by the model, if any. + * + *

Note: If there are multiple choices only the first one's reasoning content is returned. + * + * @return the list of reasoning blocks; never {@code null}, may be empty. + * @see SAP AI Core: + * Orchestration - Reasoning + */ + @SuppressWarnings("PMD.PublicApiExposesModelType") + @Nonnull + public List getReasoningContent() { + return getChoice().getMessage().getReasoningContent(); + } + + /** + * Get the reasoning (thinking) content as a single joined string, for display purposes. + * + * @return the concatenated content of all reasoning blocks; empty if there is no reasoning. + */ + @Nonnull + public String getReasoningText() { + return getReasoningContent().stream().map(ReasoningBlock::getContent).collect(joining()); + } + /** * Get all messages. This can be used for subsequent prompts as a message history. * @@ -80,13 +107,19 @@ public List getAllMessages() throws IllegalArgumentException { originalResponse.getIntermediateResults().getTemplating()) { if (chatMessage instanceof AssistantChatMessage assistantChatMessage) { val toolCalls = assistantChatMessage.getToolCalls(); + AssistantMessage assistantMessage; if (!toolCalls.isEmpty()) { - messages.add(new AssistantMessage(toolCalls)); + assistantMessage = new AssistantMessage(toolCalls); } else { - messages.add( + assistantMessage = new AssistantMessage( - MessageContent.fromChatMessageContent(assistantChatMessage.getContent()))); + MessageContent.fromChatMessageContent(assistantChatMessage.getContent())); + } + final var reasoning = assistantChatMessage.getReasoningContent(); + if (!reasoning.isEmpty()) { + assistantMessage = assistantMessage.withReasoningContent(reasoning); } + messages.add(assistantMessage); } else if (chatMessage instanceof SystemChatMessage systemChatMessage) { messages.add( new SystemMessage( @@ -106,7 +139,12 @@ public List getAllMessages() throws IllegalArgumentException { } } - messages.add(Message.assistant(getChoice().getMessage().getContent())); + var lastAssistant = Message.assistant(getChoice().getMessage().getContent()); + final var lastReasoning = getReasoningContent(); + if (!lastReasoning.isEmpty()) { + lastAssistant = lastAssistant.withReasoningContent(lastReasoning); + } + messages.add(lastAssistant); return messages; } diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java index 2e568a60b..26aac17e9 100644 --- a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/OrchestrationClient.java @@ -18,6 +18,7 @@ import com.sap.ai.sdk.orchestration.model.GlobalStreamOptions; import com.sap.ai.sdk.orchestration.model.OrchestrationConfig; import com.sap.ai.sdk.orchestration.model.PartialOrchestrationConfig; +import com.sap.ai.sdk.orchestration.model.ReasoningBlock; import com.sap.cloud.sdk.cloudplatform.connectivity.Header; import com.sap.cloud.sdk.cloudplatform.connectivity.HttpDestination; import io.vavr.control.Try; @@ -131,6 +132,54 @@ public Stream streamChatCompletion( .map(OrchestrationChatCompletionDelta::getDeltaContent); } + /** + * Stream a completion from a reasoning-capable model. Answer and reasoning text chunks are pushed + * live to the given consumers (empty chunks are skipped); the accumulated reasoning blocks are + * returned for re-submission in a follow-up request. + * + * @param prompt the prompt to send. + * @param config the configuration to use. + * @param onAnswerChunk consumer invoked with each non-empty answer text chunk. + * @param onReasoningChunk consumer invoked with each non-empty reasoning text chunk. + * @param fallbackConfigs fallback configurations. + * @return the assembled reasoning blocks, preserved verbatim as returned by the API. + * @throws OrchestrationClientException if the request fails or the finish reason is {@code + * content_filter}. + * @see SAP AI Core: + * Orchestration - Reasoning + */ + @SuppressWarnings("PMD.PublicApiExposesModelType") + @Nonnull + public List streamReasoning( + @Nonnull final OrchestrationPrompt prompt, + @Nonnull final OrchestrationModuleConfig config, + @Nonnull final Consumer onAnswerChunk, + @Nonnull final Consumer onReasoningChunk, + @Nonnull final OrchestrationModuleConfig... fallbackConfigs) + throws OrchestrationClientException { + + val request = toCompletionPostRequest(prompt, config, fallbackConfigs); + val accumulator = new ReasoningAccumulator(); + try (Stream stream = streamChatCompletionDeltas(request)) { + stream + .peek(OrchestrationClient::throwOnContentFilter) + .forEach( + delta -> { + final var content = delta.getDeltaContent(); + if (!content.isEmpty()) { + onAnswerChunk.accept(content); + } + for (final var block : delta.getDeltaReasoningContent()) { + if (!block.getContent().isEmpty()) { + onReasoningChunk.accept(block.getContent()); + } + } + accumulator.accept(delta); + }); + } + return accumulator.assemble(); + } + private static void throwOnContentFilter(@Nonnull final OrchestrationChatCompletionDelta delta) throws OrchestrationFilterException.Output { final String finishReason = delta.getFinishReason(); diff --git a/orchestration/src/main/java/com/sap/ai/sdk/orchestration/ReasoningAccumulator.java b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/ReasoningAccumulator.java new file mode 100644 index 000000000..91ffc3136 --- /dev/null +++ b/orchestration/src/main/java/com/sap/ai/sdk/orchestration/ReasoningAccumulator.java @@ -0,0 +1,52 @@ +package com.sap.ai.sdk.orchestration; + +import com.sap.ai.sdk.orchestration.model.ReasoningBlock; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Nonnull; + +/** + * Accumulates reasoning ({@code reasoning_content}) blocks across the chunks of a streaming + * orchestration response. + * + * @see SAP AI Core: + * Orchestration - Reasoning + */ +public class ReasoningAccumulator { + + private final List blocks = new ArrayList<>(); + + /** + * Consume the reasoning content carried by a single stream chunk. + * + * @param delta the streaming delta. + */ + public void accept(@Nonnull final OrchestrationChatCompletionDelta delta) { + final List chunk = delta.getDeltaReasoningContent(); + for (int i = 0; i < chunk.size(); i++) { + while (blocks.size() <= i) { + blocks.add(ReasoningBlock.create().content("").signature("")); + } + final ReasoningBlock target = blocks.get(i); + final ReasoningBlock source = chunk.get(i); + if (!source.getContent().isEmpty()) { + target.setContent(target.getContent() + source.getContent()); + } + if (!source.getSignature().isEmpty() && target.getSignature().isEmpty()) { + target.setSignature(source.getSignature()); + } + } + } + + /** + * Assemble the fully accumulated list of reasoning blocks, ready to be re-submitted as part of + * {@code messages_history}. + * + * @return the assembled blocks, in the order they appeared in the stream. + */ + @SuppressWarnings("PMD.PublicApiExposesModelType") + @Nonnull + public List assemble() { + return List.copyOf(blocks); + } +} 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..9c2d92e03 --- /dev/null +++ b/orchestration/src/test/java/com/sap/ai/sdk/orchestration/OrchestrationReasoningContentTest.java @@ -0,0 +1,156 @@ +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 assistantMessageCarriesReasoningContentInSerializedOutput() { + 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); + + assertThat(message.reasoningContent()).containsExactlyElementsOf(blocks); + + val serialized = (AssistantChatMessage) message.createChatMessage(); + assertThat(serialized.getReasoningContent()).containsExactlyElementsOf(blocks); + } + + @Test + void chatResponseGetReasoningContentReturnsEmptyWhenAbsent() throws Exception { + val response = parseSyncResponse(""); + + assertThat(response.getReasoningContent()).isEmpty(); + assertThat(response.getReasoningText()).isEmpty(); + } + + @Test + void chatResponseGetReasoningContentReturnsBlocks() throws Exception { + val response = + parseSyncResponse( + ", \"reasoning_content\": [{\"content\": \"thinking...\", \"signature\": \"sig-a\"}]"); + + assertThat(response.getReasoningContent()).hasSize(1); + assertThat(response.getReasoningContent().get(0).getContent()).isEqualTo("thinking..."); + assertThat(response.getReasoningContent().get(0).getSignature()).isEqualTo("sig-a"); + assertThat(response.getReasoningText()).isEqualTo("thinking..."); + } + + @Test + void getAllMessagesAttachesReasoningToFinalAssistant() throws Exception { + val response = + parseSyncResponse( + ", \"reasoning_content\": [{\"content\": \"thinking...\", \"signature\": \"sig-a\"}]"); + + val last = (AssistantMessage) response.getLastMessage(); + assertThat(last.reasoningContent()).hasSize(1); + assertThat(last.reasoningContent().get(0).getContent()).isEqualTo("thinking..."); + } + + @Test + void getAllMessagesAttachesReasoningToHistoryAssistant() 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 historyAssistant = (AssistantMessage) messages.get(1); + assertThat(historyAssistant.reasoningContent()).hasSize(1); + assertThat(historyAssistant.reasoningContent().get(0).getContent()).isEqualTo("past-thought"); + } + + @Test + void reasoningAccumulatorKeepsBlocksAtDifferentIndexesSeparate() throws Exception { + val chunk = + parseStreamingDelta( + 0, + ", \"reasoning_content\": [" + + "{\"content\": \"first\", \"signature\": \"s1\"}," + + "{\"content\": \"second\", \"signature\": \"s2\"}]"); + + val accumulator = new ReasoningAccumulator(); + accumulator.accept(chunk); + + val blocks = accumulator.assemble(); + assertThat(blocks).hasSize(2); + assertThat(blocks.get(0).getContent()).isEqualTo("first"); + assertThat(blocks.get(0).getSignature()).isEqualTo("s1"); + assertThat(blocks.get(1).getContent()).isEqualTo("second"); + assertThat(blocks.get(1).getSignature()).isEqualTo("s2"); + } + + 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 bd49304d8..9dbb09e18 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; @@ -1678,4 +1679,101 @@ 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 answerChunks = new ArrayList(); + final var reasoningChunks = new ArrayList(); + final var reasoning = + client.streamReasoning(prompt, reasoningConfig, answerChunks::add, reasoningChunks::add); + + assertThat(answerChunks).containsExactly("42"); + assertThat(reasoningChunks).containsExactly("Let me ", "think."); + assertThat(reasoning).hasSize(1); + assertThat(reasoning.get(0).getContent()).isEqualTo("Let me think."); + assertThat(reasoning.get(0).getSignature()).isEqualTo("sig-final"); + } + + @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).getContent()) + .startsWith("This is a classic question about atmospheric physics."); + assertThat(response.getReasoningContent().get(0).getSignature()).startsWith("EtAECnAIDxABGAIq"); + assertThat(response.getReasoningText()) + .isEqualTo(response.getReasoningContent().get(0).getContent()); + + // getAllMessages() must attach the reasoning content to the final assistant message so it can + // be in messages_history on a follow-up request. + final var last = (AssistantMessage) response.getLastMessage(); + assertThat(last.reasoningContent()).hasSize(1); + assertThat(last.reasoningContent().get(0).getSignature()) + .isEqualTo(response.getReasoningContent().get(0).getSignature()); + } + + @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: real reasoning content + signature parsed correctly. + assertThat(firstResponse.getContent()).isEqualTo("6 times 7 is 42."); + assertThat(firstResponse.getReasoningContent()).hasSize(1); + assertThat(firstResponse.getReasoningContent().get(0).getSignature()) + .startsWith("EpACCnAIDxABGAIq"); + + // 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 + // in the messages_history array. This is the multi-turn round-trip contract from the docs. + 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..12f20e904 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,55 @@ 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 blocks at the end of the stream so callers can + // resubmit them in messages_history for a follow-up request. + for (int i = 0; i < accumulated.size(); i++) { + final var block = accumulated.get(i); + send( + emitter, + "[accumulated block " + + i + + "] content=" + + block.getContent() + + " signature=" + + block.getSignature()); + } + } 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..c3c9610e0 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,7 @@ 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.ReasoningEffort; import com.sap.ai.sdk.orchestration.ResponseJsonSchema; import com.sap.ai.sdk.orchestration.SystemMessage; import com.sap.ai.sdk.orchestration.TemplateConfig; @@ -40,6 +42,7 @@ import com.sap.ai.sdk.orchestration.model.DocumentGroundingFilter; import com.sap.ai.sdk.orchestration.model.GroundingFilterSearchConfiguration; import com.sap.ai.sdk.orchestration.model.LlamaGuard38b; +import com.sap.ai.sdk.orchestration.model.ReasoningBlock; import com.sap.ai.sdk.orchestration.model.ResponseFormatText; import com.sap.ai.sdk.orchestration.model.SearchDocumentKeyValueListPair; import com.sap.ai.sdk.orchestration.model.SearchSelectOptionEnum; @@ -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,79 @@ 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) blocks. + * + * @param answer the final assistant reply. + * @param reasoning the reasoning blocks produced by the model (maybe 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 blocks are returned for re-submission in + * a follow-up request. + * + * @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 blocks 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); + return client.streamReasoning(prompt, reasoningConfig, onAnswerChunk, onReasoningChunk); + } + + /** + * 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

+
  • +
    + +
    + Chat request against a reasoning-capable model. Returns both the final answer and the reasoning blocks. +
    +
    +
  • +
  • +
    + +
    + Streaming variant that emits answer and reasoning chunks live, followed by the accumulated reasoning blocks. +
    +
    +
  • +
  • +
    + +
    + Two-turn conversation demonstrating re-submission of reasoning_content in messages_history. +
    +
    +